diff --git a/.changeset/ready-rivers-crash.md b/.changeset/ready-rivers-crash.md new file mode 100644 index 0000000..8271f60 --- /dev/null +++ b/.changeset/ready-rivers-crash.md @@ -0,0 +1,5 @@ +--- +"wtc": patch +--- + +feature: adds task selection diff --git a/_specs/task-200-ok.json b/_specs/task-200-ok.json new file mode 100644 index 0000000..0e2a5e9 --- /dev/null +++ b/_specs/task-200-ok.json @@ -0,0 +1,1708 @@ +{ + "affected": { + "linkedCardId": 0, + "linkedColumnId": 0, + "linkedColumnName": "string", + "taskIds": [0] + }, + "included": { + "comments": { + "additionalProp": { + "id": 0, + "object": { + "id": 0, + "meta": {}, + "type": "string" + }, + "objectId": 0, + "objectType": "string", + "postedAt": "string", + "postedBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "project": { + "id": 0, + "meta": {}, + "type": "string" + }, + "title": "string" + } + }, + "companies": { + "additionalProp": { + "accounts": 0, + "addressOne": "string", + "addressTwo": "string", + "budgetDistribution": [ + { + "color": "string", + "companyId": 0, + "count": 0, + "from": 0, + "to": 0 + } + ], + "canSeePrivate": true, + "cid": "string", + "city": "string", + "clientManagedBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "clients": 0, + "collaborators": 0, + "companyDomains": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "companyNameUrl": "string", + "companyUpdate": { + "id": 0, + "meta": {}, + "type": "string" + }, + "contacts": 0, + "countryCode": "string", + "createdAt": "string", + "currency": { + "id": 0, + "meta": {}, + "type": "string" + }, + "emailOne": "string", + "emailThree": "string", + "emailTwo": "string", + "fax": "string", + "financialBudgetSummary": { + "totalCapacity": 0, + "totalCapacityUsed": 0 + }, + "id": 0, + "industry": { + "id": 0, + "meta": {}, + "type": "string" + }, + "industryId": 0, + "isOwner": true, + "logoUrl": "string", + "name": "string", + "phone": "string", + "privateNotes": "string", + "privateNotesText": "string", + "profileText": "string", + "profitability": { + "billable": 0, + "billableTime": 0, + "companyCount": 0, + "cost": 0, + "expenses": 0, + "expensesBillableTotal": 0, + "loggedTime": 0, + "nonBillableTime": 0, + "ownerCount": 0, + "profit": 0, + "profitPercentage": 0, + "profitTargetPercentage": 0, + "targetCostsDollars": 0, + "targetProfitDollars": 0 + }, + "rates": [ + { + "createdAt": "string", + "createdByUser": { + "id": 0, + "meta": {}, + "type": "string" + }, + "rate": { + "amount": 0, + "currency": { + "id": 0, + "meta": {}, + "type": "string" + } + }, + "role": { + "id": 0, + "meta": {}, + "type": "string" + }, + "updatedAt": "string", + "updatedByUser": { + "id": 0, + "meta": {}, + "type": "string" + } + } + ], + "state": "string", + "stats": { + "projectCount": 0, + "taskCompleteCount": 0, + "taskCount": 0, + "unreadEmailCount": 0 + }, + "status": "string", + "tags": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "timeBudgetSummary": { + "totalCapacity": 0, + "totalCapacityUsed": 0 + }, + "updatedAt": "string", + "website": "string", + "zip": "string" + } + }, + "customfieldTasks": { + "additionalProp": { + "countryCode": "string", + "createdAt": "string", + "createdBy": 0, + "currencySymbol": "string", + "customfield": { + "id": 0, + "meta": {}, + "type": "string" + }, + "customfieldId": 0, + "id": 0, + "task": { + "id": 0, + "meta": {}, + "type": "string" + }, + "taskId": 0, + "urlTextToDisplay": "string", + "value": "Unknown Type: any" + } + }, + "customfields": { + "additionalProp": { + "createdAt": "string", + "createdBy": 0, + "createdByUserId": 0, + "currencyCode": "string", + "deleted": true, + "deletedAt": "string", + "deletedBy": 0, + "deletedByUserId": 0, + "description": "string", + "entity": "string", + "formula": "string", + "groupId": 0, + "id": 0, + "isPrivate": true, + "name": "string", + "options": "Unknown Type: any", + "project": { + "id": 0, + "meta": {}, + "type": "string" + }, + "projectId": 0, + "required": true, + "type": "string", + "unitId": 0, + "updatedAt": "string", + "updatedBy": 0, + "updatedByUserId": 0, + "visibilities": ["string"] + } + }, + "files": { + "additionalProp": { + "category": { + "id": 0, + "meta": {}, + "type": "string" + }, + "categoryId": 0, + "changeFollowers": { + "companies": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "companyIds": [0], + "teamIds": [0], + "teams": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "userIds": [0], + "users": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ] + }, + "commentFollowers": { + "companies": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "companyIds": [0], + "teamIds": [0], + "teams": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "userIds": [0], + "users": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ] + }, + "commentsCount": 0, + "commentsCountRead": 0, + "deletedAt": "string", + "deletedBy": 0, + "description": "string", + "displayName": "string", + "downloadURL": "string", + "extraData": "string", + "fileSource": "string", + "filenameOnDisk": "string", + "id": 0, + "isLocked": true, + "isPrivate": "0", + "latestFileVersionNo": 0, + "lockdown": { + "id": 0, + "meta": {}, + "type": "string" + }, + "lockdownId": 0, + "lockedAt": "string", + "lockedBy": 0, + "lockedByUserId": 0, + "lockedDate": "string", + "meta": {}, + "numLikes": 0, + "originalName": "string", + "previewURL": "string", + "project": { + "id": 0, + "meta": {}, + "type": "string" + }, + "projectId": 0, + "reactions": { + "counts": { + "dislike": 0, + "frown": 0, + "heart": 0, + "joy": 0, + "like": 0 + }, + "extendedReactions": [ + { + "count": 0, + "emoji": "string", + "mine": true + } + ], + "mine": ["string"] + }, + "relatedItems": { + "comments": [0], + "messages": [0], + "tasks": [0] + }, + "shareable": true, + "size": 0, + "status": "string", + "tagIds": [0], + "tags": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "thumbURL": "string", + "updatedAt": "string", + "uploadedAt": "string", + "uploadedBy": 0, + "uploadedByUserID": 0, + "uploadedDate": "string", + "version": { + "id": 0, + "meta": {}, + "type": "string" + }, + "versionId": 0, + "versions": [ + { + "commentsCount": 0, + "commentsCountRead": 0, + "description": "string", + "displayName": "string", + "file": { + "id": 0, + "meta": {}, + "type": "string" + }, + "fileId": 0, + "fileVersionId": 0, + "name": "string", + "originalName": "string", + "project": { + "id": 0, + "meta": {}, + "type": "string" + }, + "projectId": 0, + "reactions": { + "counts": { + "dislike": 0, + "frown": 0, + "heart": 0, + "joy": 0, + "like": 0 + }, + "extendedReactions": [ + { + "count": 0, + "emoji": "string", + "mine": true + } + ], + "mine": ["string"] + }, + "size": 0, + "status": "string", + "uploadedAt": "string", + "uploadedBy": 0, + "versionNo": 0 + } + ] + } + }, + "jobRoles": { + "additionalProp": { + "billableRatesByCurrencyId": { + "additionalProp": { + "createdAt": "string", + "createdByUserId": 0, + "rate": { + "amount": 0, + "currency": { + "id": 0, + "meta": {}, + "type": "string" + } + }, + "updatedAt": "string", + "updatedByUserId": 0, + "validFrom": "string" + } + }, + "costRatesByCurrencyId": { + "additionalProp": { + "createdAt": "string", + "createdByUserId": 0, + "rate": { + "amount": 0, + "currency": { + "id": 0, + "meta": {}, + "type": "string" + } + }, + "updatedAt": "string", + "updatedByUserId": 0 + } + }, + "createdAt": "string", + "createdByUser": 0, + "deletedAt": "string", + "deletedByUser": 0, + "id": 0, + "isActive": true, + "name": "string", + "placeholderCount": 0, + "primaryUsers": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "updatedAt": "string", + "updatedByUser": 0, + "users": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ] + } + }, + "lockdowns": { + "additionalProp": { + "grantAccessTo": { + "companies": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "companyIds": [0], + "teamIds": [0], + "teams": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "userIds": [0], + "users": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ] + }, + "id": 0, + "items": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "updatedAt": "string", + "user": { + "id": 0, + "meta": {}, + "type": "string" + }, + "userID": 0 + } + }, + "milestones": { + "additionalProp": { + "canComplete": true, + "canEdit": true, + "changeFollowerIds": [0], + "changeFollowers": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "commentFollowerIds": [0], + "commentFollowers": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "commentsCount": 0, + "completed": true, + "completedBy": 0, + "completedOn": "string", + "completerId": 0, + "createdBy": 0, + "createdOn": "string", + "creatorUserId": 0, + "deadline": "string", + "deletedOn": "string", + "description": "string", + "descriptionHTML": "string", + "id": 0, + "isDeleted": true, + "lastChangedOn": "string", + "latestUpdates": [ + { + "after": "Unknown Type: any", + "before": "Unknown Type: any", + "field": "string" + } + ], + "lockdown": { + "id": 0, + "meta": {}, + "type": "string" + }, + "lockdownId": 0, + "name": "string", + "numCommentsRead": 0, + "originalDueDate": "string", + "percentageComplete": 0, + "percentageTasksCompleted": 0, + "private": true, + "project": { + "id": 0, + "meta": {}, + "type": "string" + }, + "projectId": 0, + "reminder": true, + "responsibleParties": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "responsiblePartyIds": [0], + "status": "string", + "tagIds": [0], + "tags": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "tasklistIds": [0], + "tasklists": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "updatedBy": 0, + "userFollowingChanges": true, + "userFollowingComments": true, + "userFollowingComplete": true + } + }, + "projectCategories": { + "additionalProp": { + "color": "string", + "count": 0, + "id": 0, + "name": "string", + "parent": { + "id": 0, + "meta": {}, + "type": "string" + }, + "parentId": 0 + } + }, + "projectIntegrations": { + "additionalProp": { + "canAccessBox": true, + "canAccessDropbox": true, + "canAccessGoogleDocs": true, + "canAccessOneDrive": true, + "canAccessOneDriveBusiness": true, + "canAccessSharePoint": true, + "projectId": 0, + "userId": 0 + } + }, + "projectPermissions": { + "additionalProp": { + "active": true, + "addExpenses": true, + "addFiles": true, + "addForms": true, + "addLinks": true, + "addMessages": true, + "addMilestones": true, + "addNotebooks": true, + "addProjectUpdate": true, + "addRisks": true, + "addTaskLists": true, + "addTasks": true, + "addTime": true, + "canAccess": true, + "canEditWorkflows": true, + "canManagePeople": true, + "canManageProjectBudget": true, + "canManageProjectMembership": true, + "canManageProjectTemplates": true, + "canManageRates": true, + "canManageSchedule": true, + "canViewForms": true, + "canViewProjectBudget": true, + "canViewProjectMembers": true, + "canViewProjectProfitability": true, + "canViewProjectTemplates": true, + "canViewRates": true, + "canViewSchedule": true, + "canViewWorkflows": true, + "editAllTasks": true, + "inOwnerCompany": true, + "isArchived": true, + "isObserving": true, + "manageCustomFields": true, + "projectAdministrator": true, + "receiveEmailNotifications": true, + "setPrivacy": true, + "viewAllTimeLogs": true, + "viewEstimatedTime": true, + "viewInvoices": true, + "viewLinks": true, + "viewMessagesAndFiles": true, + "viewNotebooks": true, + "viewProjectUpdate": true, + "viewRiskRegister": true, + "viewTasksAndMilestones": true, + "viewTime": true + } + }, + "projects": { + "additionalProp": { + "activePages": { + "billing": true, + "board": true, + "comments": true, + "files": true, + "finance": true, + "forms": true, + "gantt": true, + "links": true, + "list": true, + "messages": true, + "milestones": true, + "notebooks": true, + "proofs": true, + "riskRegister": true, + "schedule": true, + "table": true, + "tasks": true, + "tickets": true, + "time": true, + "timeline": true + }, + "allowNotifyAnyone": true, + "announcement": "string", + "archivedAt": "string", + "archivedBy": 0, + "category": { + "id": 0, + "meta": {}, + "type": "string" + }, + "categoryId": 0, + "company": { + "id": 0, + "meta": {}, + "type": "string" + }, + "companyId": 0, + "completedAt": "string", + "completedBy": 0, + "createdAt": "string", + "createdBy": 0, + "customFieldValueIds": [0], + "customfieldValues": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "defaultPrivacy": "string", + "deletedAt": "string", + "deletedBy": 0, + "description": "string", + "directFileUploadsEnabled": true, + "endAt": "string", + "endDate": "string", + "financialBudget": { + "id": 0, + "meta": {}, + "type": "string" + }, + "financialBudgetId": 0, + "harvestTimersEnabled": true, + "id": 0, + "integrations": { + "googleDrive": { + "access": "string", + "enabled": true, + "folder": "string", + "folderName": "string" + }, + "oneDriveBusiness": { + "account": "string", + "enabled": true, + "folder": "string", + "folderName": "string" + }, + "sharepoint": { + "account": "string", + "enabled": true, + "folder": "string", + "folderName": "string" + }, + "xero": { + "baseCurrency": "string", + "connected": true, + "countryCode": "string", + "enabled": true, + "organisation": "string" + } + }, + "isBillable": true, + "isOnBoardingProject": true, + "isProjectAdmin": true, + "isSampleProject": true, + "isStarred": true, + "lastWorkedOn": "string", + "latestActivity": { + "id": 0, + "meta": {}, + "type": "string" + }, + "logo": "string", + "logoColor": "string", + "logoIcon": "string", + "minMaxAvailableDates": { + "deadlinesFound": true, + "maxEndDate": "string", + "minStartDate": "string", + "suggestedEndDate": "string", + "suggestedStartDate": "string" + }, + "name": "string", + "notifyCommentIncludeCreator": true, + "notifyEveryone": true, + "notifyTaskAssignee": true, + "overviewStartPage": "string", + "ownedBy": 0, + "ownerId": 0, + "portfolioCards": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "privacyEnabled": true, + "projectOwner": { + "id": 0, + "meta": {}, + "type": "string" + }, + "projectOwnerId": 0, + "replyByEmailEnabled": true, + "showAnnouncement": true, + "skipWeekends": true, + "startAt": "string", + "startDate": "string", + "startPage": "string", + "status": "active", + "subStatus": "current", + "tagIds": [0], + "tags": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "tasksStartPage": "string", + "timeBudget": { + "id": 0, + "meta": {}, + "type": "string" + }, + "timeBudgetId": 0, + "timelogRequiresTask": true, + "type": "normal", + "update": { + "id": 0, + "meta": {}, + "type": "string" + }, + "updateId": 0, + "updatedAt": "string", + "updatedBy": 0, + "users": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "workflowIds": [0], + "workflows": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ] + } + }, + "proofs": { + "additionalProp": { + "assignees": [ + { + "decision": "string", + "id": 0, + "isExternal": true, + "role": "string", + "user": { + "id": 0, + "meta": {}, + "type": "string" + } + } + ], + "company": { + "id": 0, + "meta": {}, + "type": "string" + }, + "createdAt": "string", + "createdBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "deletedAt": "string", + "deletedBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "description": "string", + "due": "2026-06-22", + "entity": { + "id": 0, + "meta": {}, + "type": "string" + }, + "errorObjectIdxs": [0], + "feedbackCount": 0, + "id": 0, + "installationId": { + "id": 0, + "meta": {}, + "type": "string" + }, + "objects": [ + { + "URL": "string", + "approvalStatus": "string", + "createdAt": "string", + "createdBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "deletedAt": "string", + "deletedBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "description": "string", + "due": "string", + "format": "string", + "id": 0, + "installationId": { + "id": 0, + "meta": {}, + "type": "string" + }, + "size": 0, + "sourceFormat": "string", + "state": "string", + "stateMessage": "string", + "thumbnail": "string", + "title": "string", + "totalChunks": 0, + "type": "string", + "updatedAt": "string", + "updatedBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "version": 0 + } + ], + "parent": { + "id": 0, + "meta": {}, + "type": "string" + }, + "product": "string", + "state": "string", + "status": "string", + "title": "string", + "updatedAt": "string", + "updatedBy": { + "id": 0, + "meta": {}, + "type": "string" + } + } + }, + "stages": { + "additionalProp": { + "color": "string", + "createdAt": "string", + "createdBy": 0, + "deletedAt": "string", + "deletedBy": 0, + "displayOrder": 0, + "id": 0, + "name": "string", + "showCompletedTasks": true, + "taskIds": [0], + "updatedAt": "string", + "updatedBy": 0, + "workflow": { + "id": 0, + "meta": {}, + "type": "string" + } + } + }, + "subtaskStats": { + "additionalProp": { + "active": 0, + "complete": 0, + "id": 0, + "late": 0 + } + }, + "tags": { + "additionalProp": { + "color": "string", + "count": 0, + "id": 0, + "name": "string", + "project": { + "id": 0, + "meta": {}, + "type": "string" + }, + "projectId": 0 + } + }, + "taskSequences": { + "additionalProp": { + "dates": ["2026-06-22"], + "duration": 0, + "endDate": "string", + "frequency": "string", + "id": 0, + "installationId": 0, + "monthlyRepeatType": "string", + "rrule": "string", + "selectedWeekDays": ["string"], + "tasks": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ] + } + }, + "taskgroups": { + "additionalProp": { + "createdAt": "string", + "createdBy": 0, + "description": "string", + "displayOrder": 0, + "id": 0, + "name": "string", + "status": "string", + "taskIds": [0], + "updatedAt": "string", + "updatedBy": 0 + } + }, + "tasklists": { + "additionalProp": { + "calculatedDueDate": "string", + "calculatedStartDate": "string", + "createdAt": "string", + "defaultTask": { + "id": 0, + "meta": {}, + "type": "string" + }, + "defaultTaskId": 0, + "description": "string", + "displayOrder": 0, + "icon": "string", + "id": 0, + "isBillable": true, + "isPinned": true, + "isPrivate": true, + "lockdownId": 0, + "milestone": { + "id": 0, + "meta": {}, + "type": "string" + }, + "milestoneId": 0, + "name": "string", + "project": { + "id": 0, + "meta": {}, + "type": "string" + }, + "projectId": 0, + "status": "string", + "tasklistBudget": { + "id": 0, + "meta": {}, + "type": "string" + }, + "updatedAt": "string" + } + }, + "tasks": { + "additionalProp": { + "accumulatedEstimatedMinutes": 0, + "assigneeCompanies": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "assigneeCompanyIds": [0], + "assigneeJobRoleIds": [0], + "assigneeJobRoles": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "assigneeTeamIds": [0], + "assigneeTeams": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "assigneeUserIds": [0], + "assigneeUsers": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "assignees": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "attachments": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "capacities": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "changeFollowers": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "column": { + "id": 0, + "meta": {}, + "type": "string" + }, + "commentFollowers": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "completeFollowers": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "completedAt": "string", + "completedBy": 0, + "completedOn": "string", + "createdAt": "string", + "createdBy": 0, + "createdByUserId": 0, + "crmDealIds": [0], + "dateUpdated": "string", + "decimalDisplayOrder": 0, + "deletedAt": "string", + "deletedBy": 0, + "dependencyIds": [0], + "description": "string", + "descriptionContentType": "string", + "displayOrder": 0, + "dueDate": {}, + "dueDateBase": {}, + "dueDateFromMilestone": true, + "dueDateOffset": 0, + "estimateMinutes": 0, + "hasDeskTickets": true, + "hasReminders": true, + "hasTimeblocks": true, + "id": 0, + "isArchived": true, + "isBlocked": true, + "isPrivate": 0, + "latestUpdates": [ + { + "after": "Unknown Type: any", + "before": "Unknown Type: any", + "field": "string" + } + ], + "lockdown": { + "id": 0, + "meta": {}, + "type": "string" + }, + "meta": {}, + "name": "string", + "notify": true, + "originalDueDate": "2026-06-22", + "outOfSequence": true, + "parentTask": { + "id": 0, + "meta": {}, + "type": "string" + }, + "parentTaskId": 0, + "predecessorIds": [0], + "predecessors": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "priority": "string", + "progress": 0, + "proofs": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "sequence": { + "id": 0, + "meta": {}, + "type": "string" + }, + "sequenceDueDate": "2026-06-22", + "sequenceId": 0, + "sequenceRootTask": true, + "sequenceStartDate": "2026-06-22", + "startDate": {}, + "startDateOffset": 0, + "status": "string", + "subTaskIds": [0], + "tagIds": [0], + "tags": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "tasklist": { + "id": 0, + "meta": {}, + "type": "string" + }, + "tasklistId": 0, + "templateRoleName": "string", + "timer": { + "id": 0, + "meta": {}, + "type": "string" + }, + "updatedAt": "string", + "updatedBy": 0, + "userPermissions": { + "canAddSubtasks": true, + "canComplete": true, + "canEdit": true, + "canLogTime": true, + "canViewEstTime": true + }, + "workflowStages": [ + { + "stageId": 0, + "stageTaskDisplayOrder": 0, + "workflowId": 0 + } + ] + } + }, + "teams": { + "additionalProp": { + "company": { + "id": 0, + "meta": {}, + "type": "string" + }, + "createdAt": "string", + "createdBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "handle": "string", + "id": 0, + "name": "string", + "teamLogo": "string", + "teamLogoColor": "string", + "teamLogoIcon": "string", + "updatedAt": "string", + "updatedBy": { + "id": 0, + "meta": {}, + "type": "string" + } + } + }, + "timeTotals": { + "additionalProp": { + "accumulatedBillableLoggedMinutes": 0, + "accumulatedBilledloggedMinutes": 0, + "accumulatedLoggedMinutes": 0, + "billableLoggedMinutes": 0, + "billedloggedMinutes": 0, + "loggedMinutes": 0 + } + }, + "timers": { + "additionalProp": { + "billable": true, + "createdAt": "string", + "deleted": true, + "deletedAt": "string", + "description": "string", + "duration": 0, + "id": 0, + "intervals": [ + { + "duration": 0, + "from": "string", + "id": 0, + "to": "string" + } + ], + "lastStartedAt": "string", + "project": { + "id": 0, + "meta": {}, + "type": "string" + }, + "projectId": 0, + "running": true, + "serverTime": "string", + "task": { + "id": 0, + "meta": {}, + "type": "string" + }, + "taskId": 0, + "timeLogId": 0, + "timelog": { + "id": 0, + "meta": {}, + "type": "string" + }, + "timerLastIntervalEnd": "string", + "updatedAt": "string", + "user": { + "id": 0, + "meta": {}, + "type": "string" + }, + "userId": 0 + } + }, + "users": { + "additionalProp": { + "avatarUrl": "string", + "canAccessPortfolio": true, + "canAddProjects": true, + "canManagePortfolio": true, + "company": { + "id": 0, + "meta": {}, + "type": "string" + }, + "companyId": 0, + "companyRoleId": 0, + "createdAt": "string", + "createdBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "deleted": true, + "email": "string", + "firstName": "string", + "id": 0, + "isAdmin": true, + "isClientUser": true, + "isPlaceholderResource": true, + "isServiceAccount": true, + "jobRoles": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "lastLogin": "string", + "lastName": "string", + "lengthOfDay": 0, + "meta": {}, + "skills": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "teams": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "timezone": "string", + "title": "string", + "type": "string", + "updatedAt": "string", + "updatedBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "userCost": 0, + "userRate": 0, + "userRates": { + "additionalProp": { + "amount": 0, + "currency": { + "id": 0, + "meta": {}, + "type": "string" + } + } + }, + "workingHour": { + "id": 0, + "meta": {}, + "type": "string" + }, + "workingHoursId": 0 + } + }, + "workflows": { + "additionalProp": { + "companies": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "createdAt": "string", + "createdBy": 0, + "defaultWorkflow": true, + "id": 0, + "lockdown": { + "id": 0, + "meta": {}, + "type": "string" + }, + "name": "string", + "projectIds": [0], + "projectSpecific": true, + "stages": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "status": "string", + "updatedAt": "string", + "updatedBy": 0 + } + } + }, + "meta": { + "averageSpend": 0, + "data": {}, + "limit": 0, + "nextCursor": "string", + "page": { + "count": 0, + "hasMore": true, + "pageOffset": 0, + "pageSize": 0 + }, + "prevCursor": "string", + "totalCapacity": 0 + }, + "task": { + "accumulatedEstimatedMinutes": 0, + "assigneeCompanies": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "assigneeCompanyIds": [0], + "assigneeJobRoleIds": [0], + "assigneeJobRoles": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "assigneeTeamIds": [0], + "assigneeTeams": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "assigneeUserIds": [0], + "assigneeUsers": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "assignees": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "attachments": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "capacities": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "changeFollowers": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "column": { + "id": 0, + "meta": {}, + "type": "string" + }, + "commentFollowers": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "completeFollowers": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "completedAt": "string", + "completedBy": 0, + "completedOn": "string", + "createdAt": "string", + "createdBy": 0, + "createdByUserId": 0, + "crmDealIds": [0], + "dateUpdated": "string", + "decimalDisplayOrder": 0, + "deletedAt": "string", + "deletedBy": 0, + "dependencyIds": [0], + "description": "string", + "descriptionContentType": "string", + "displayOrder": 0, + "dueDate": {}, + "dueDateBase": {}, + "dueDateFromMilestone": true, + "dueDateOffset": 0, + "estimateMinutes": 0, + "hasDeskTickets": true, + "hasReminders": true, + "hasTimeblocks": true, + "id": 0, + "isArchived": true, + "isBlocked": true, + "isPrivate": 0, + "latestUpdates": [ + { + "after": "Unknown Type: any", + "before": "Unknown Type: any", + "field": "string" + } + ], + "lockdown": { + "id": 0, + "meta": {}, + "type": "string" + }, + "meta": {}, + "name": "string", + "notify": true, + "originalDueDate": "2026-06-22", + "outOfSequence": true, + "parentTask": { + "id": 0, + "meta": {}, + "type": "string" + }, + "parentTaskId": 0, + "predecessorIds": [0], + "predecessors": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "priority": "string", + "progress": 0, + "proofs": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "sequence": { + "id": 0, + "meta": {}, + "type": "string" + }, + "sequenceDueDate": "2026-06-22", + "sequenceId": 0, + "sequenceRootTask": true, + "sequenceStartDate": "2026-06-22", + "startDate": {}, + "startDateOffset": 0, + "status": "string", + "subTaskIds": [0], + "tagIds": [0], + "tags": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "tasklist": { + "id": 0, + "meta": {}, + "type": "string" + }, + "tasklistId": 0, + "templateRoleName": "string", + "timer": { + "id": 0, + "meta": {}, + "type": "string" + }, + "updatedAt": "string", + "updatedBy": 0, + "userPermissions": { + "canAddSubtasks": true, + "canComplete": true, + "canEdit": true, + "canLogTime": true, + "canViewEstTime": true + }, + "workflowStages": [ + { + "stageId": 0, + "stageTaskDisplayOrder": 0, + "workflowId": 0 + } + ] + } +} diff --git a/_specs/tasklist-200-ok.json b/_specs/tasklist-200-ok.json new file mode 100644 index 0000000..b4f235f --- /dev/null +++ b/_specs/tasklist-200-ok.json @@ -0,0 +1,751 @@ +{ + "included": { + "ProjectPermissions": { + "additionalProp": { + "active": true, + "addExpenses": true, + "addFiles": true, + "addForms": true, + "addLinks": true, + "addMessages": true, + "addMilestones": true, + "addNotebooks": true, + "addProjectUpdate": true, + "addRisks": true, + "addTaskLists": true, + "addTasks": true, + "addTime": true, + "canAccess": true, + "canEditWorkflows": true, + "canManagePeople": true, + "canManageProjectBudget": true, + "canManageProjectMembership": true, + "canManageProjectTemplates": true, + "canManageRates": true, + "canManageSchedule": true, + "canViewForms": true, + "canViewProjectBudget": true, + "canViewProjectMembers": true, + "canViewProjectProfitability": true, + "canViewProjectTemplates": true, + "canViewRates": true, + "canViewSchedule": true, + "canViewWorkflows": true, + "editAllTasks": true, + "inOwnerCompany": true, + "isArchived": true, + "isObserving": true, + "manageCustomFields": true, + "projectAdministrator": true, + "receiveEmailNotifications": true, + "setPrivacy": true, + "viewAllTimeLogs": true, + "viewEstimatedTime": true, + "viewInvoices": true, + "viewLinks": true, + "viewMessagesAndFiles": true, + "viewNotebooks": true, + "viewProjectUpdate": true, + "viewRiskRegister": true, + "viewTasksAndMilestones": true, + "viewTime": true + } + }, + "companies": { + "additionalProp": { + "accounts": 0, + "addressOne": "string", + "addressTwo": "string", + "budgetDistribution": [ + { + "color": "string", + "companyId": 0, + "count": 0, + "from": 0, + "to": 0 + } + ], + "canSeePrivate": true, + "cid": "string", + "city": "string", + "clientManagedBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "clients": 0, + "collaborators": 0, + "companyDomains": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "companyNameUrl": "string", + "companyUpdate": { + "id": 0, + "meta": {}, + "type": "string" + }, + "contacts": 0, + "countryCode": "string", + "createdAt": "string", + "currency": { + "id": 0, + "meta": {}, + "type": "string" + }, + "emailOne": "string", + "emailThree": "string", + "emailTwo": "string", + "fax": "string", + "financialBudgetSummary": { + "totalCapacity": 0, + "totalCapacityUsed": 0 + }, + "id": 0, + "industry": { + "id": 0, + "meta": {}, + "type": "string" + }, + "industryId": 0, + "isOwner": true, + "logoUrl": "string", + "name": "string", + "phone": "string", + "privateNotes": "string", + "privateNotesText": "string", + "profileText": "string", + "profitability": { + "billable": 0, + "billableTime": 0, + "companyCount": 0, + "cost": 0, + "expenses": 0, + "expensesBillableTotal": 0, + "loggedTime": 0, + "nonBillableTime": 0, + "ownerCount": 0, + "profit": 0, + "profitPercentage": 0, + "profitTargetPercentage": 0, + "targetCostsDollars": 0, + "targetProfitDollars": 0 + }, + "rates": [ + { + "createdAt": "string", + "createdByUser": { + "id": 0, + "meta": {}, + "type": "string" + }, + "rate": { + "amount": 0, + "currency": { + "id": 0, + "meta": {}, + "type": "string" + } + }, + "role": { + "id": 0, + "meta": {}, + "type": "string" + }, + "updatedAt": "string", + "updatedByUser": { + "id": 0, + "meta": {}, + "type": "string" + } + } + ], + "state": "string", + "stats": { + "projectCount": 0, + "taskCompleteCount": 0, + "taskCount": 0, + "unreadEmailCount": 0 + }, + "status": "string", + "tags": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "timeBudgetSummary": { + "totalCapacity": 0, + "totalCapacityUsed": 0 + }, + "updatedAt": "string", + "website": "string", + "zip": "string" + } + }, + "customfieldTasks": { + "additionalProp": { + "countryCode": "string", + "createdAt": "string", + "createdBy": 0, + "currencySymbol": "string", + "customfield": { + "id": 0, + "meta": {}, + "type": "string" + }, + "customfieldId": 0, + "id": 0, + "task": { + "id": 0, + "meta": {}, + "type": "string" + }, + "taskId": 0, + "urlTextToDisplay": "string", + "value": "Unknown Type: any" + } + }, + "customfields": { + "additionalProp": { + "createdAt": "string", + "createdBy": 0, + "createdByUserId": 0, + "currencyCode": "string", + "deleted": true, + "deletedAt": "string", + "deletedBy": 0, + "deletedByUserId": 0, + "description": "string", + "entity": "string", + "formula": "string", + "groupId": 0, + "id": 0, + "isPrivate": true, + "name": "string", + "options": "Unknown Type: any", + "project": { + "id": 0, + "meta": {}, + "type": "string" + }, + "projectId": 0, + "required": true, + "type": "string", + "unitId": 0, + "updatedAt": "string", + "updatedBy": 0, + "updatedByUserId": 0, + "visibilities": ["string"] + } + }, + "defaultTaskReminders": { + "additionalProp": { + "createdAt": "string", + "createdBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "id": 0, + "isRelative": true, + "note": "string", + "relativeNumberDays": 0, + "remindAt": "string", + "task": { + "id": 0, + "meta": {}, + "type": "string" + }, + "type": "string", + "user": { + "id": 0, + "meta": {}, + "type": "string" + }, + "wasSent": true + } + }, + "jobRoles": { + "additionalProp": { + "billableRatesByCurrencyId": { + "additionalProp": { + "createdAt": "string", + "createdByUserId": 0, + "rate": { + "amount": 0, + "currency": { + "id": 0, + "meta": {}, + "type": "string" + } + }, + "updatedAt": "string", + "updatedByUserId": 0, + "validFrom": "string" + } + }, + "costRatesByCurrencyId": { + "additionalProp": { + "createdAt": "string", + "createdByUserId": 0, + "rate": { + "amount": 0, + "currency": { + "id": 0, + "meta": {}, + "type": "string" + } + }, + "updatedAt": "string", + "updatedByUserId": 0 + } + }, + "createdAt": "string", + "createdByUser": 0, + "deletedAt": "string", + "deletedByUser": 0, + "id": 0, + "isActive": true, + "name": "string", + "placeholderCount": 0, + "primaryUsers": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "updatedAt": "string", + "updatedByUser": 0, + "users": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ] + } + }, + "lockdowns": { + "additionalProp": { + "grantAccessTo": { + "companies": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "companyIds": [0], + "teamIds": [0], + "teams": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "userIds": [0], + "users": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ] + }, + "id": 0, + "items": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "updatedAt": "string", + "user": { + "id": 0, + "meta": {}, + "type": "string" + }, + "userID": 0 + } + }, + "milestones": { + "additionalProp": { + "canComplete": true, + "canEdit": true, + "changeFollowerIds": [0], + "changeFollowers": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "commentFollowerIds": [0], + "commentFollowers": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "commentsCount": 0, + "completed": true, + "completedBy": 0, + "completedOn": "string", + "completerId": 0, + "createdBy": 0, + "createdOn": "string", + "creatorUserId": 0, + "deadline": "string", + "deletedOn": "string", + "description": "string", + "descriptionHTML": "string", + "id": 0, + "isDeleted": true, + "lastChangedOn": "string", + "latestUpdates": [ + { + "after": "Unknown Type: any", + "before": "Unknown Type: any", + "field": "string" + } + ], + "lockdown": { + "id": 0, + "meta": {}, + "type": "string" + }, + "lockdownId": 0, + "name": "string", + "numCommentsRead": 0, + "originalDueDate": "string", + "percentageComplete": 0, + "percentageTasksCompleted": 0, + "private": true, + "project": { + "id": 0, + "meta": {}, + "type": "string" + }, + "projectId": 0, + "reminder": true, + "responsibleParties": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "responsiblePartyIds": [0], + "status": "string", + "tagIds": [0], + "tags": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "tasklistIds": [0], + "tasklists": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "updatedBy": 0, + "userFollowingChanges": true, + "userFollowingComments": true, + "userFollowingComplete": true + } + }, + "notifications": { + "additionalProp": { + "budgetId": 0, + "capacityThreshold": 0, + "companyId": 0, + "id": 0, + "notificationMedium": "string", + "teamId": 0, + "userId": 0 + } + }, + "projectIntegrations": { + "additionalProp": { + "canAccessBox": true, + "canAccessDropbox": true, + "canAccessGoogleDocs": true, + "canAccessOneDrive": true, + "canAccessOneDriveBusiness": true, + "canAccessSharePoint": true, + "projectId": 0, + "userId": 0 + } + }, + "projects": { + "additionalProp": {} + }, + "tasklistBudgets": { + "additionalProp": { + "capacity": 0, + "capacityUsed": 0, + "createdAt": "string", + "createdBy": 0, + "deletedAt": "string", + "deletedBy": 0, + "id": 0, + "installationId": 0, + "milestone": { + "id": 0, + "meta": {}, + "type": "string" + }, + "notificationIds": [0], + "notifications": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "projectBudgetId": 0, + "projectId": 0, + "projectbudget": { + "id": 0, + "meta": {}, + "type": "string" + }, + "tasklist": { + "id": 0, + "meta": {}, + "type": "string" + }, + "tasklistId": 0, + "type": "string", + "updatedAt": "string", + "updatedBy": 0 + } + }, + "tasklistTaskStats": { + "additionalProp": { + "active": 0, + "complete": 0, + "id": 0, + "late": 0 + } + }, + "tasks": { + "additionalProp": {} + }, + "teams": { + "additionalProp": { + "company": { + "id": 0, + "meta": {}, + "type": "string" + }, + "createdAt": "string", + "createdBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "handle": "string", + "id": 0, + "name": "string", + "teamLogo": "string", + "teamLogoColor": "string", + "teamLogoIcon": "string", + "updatedAt": "string", + "updatedBy": { + "id": 0, + "meta": {}, + "type": "string" + } + } + }, + "users": { + "additionalProp": { + "avatarUrl": "string", + "canAccessPortfolio": true, + "canAddProjects": true, + "canManagePortfolio": true, + "company": { + "id": 0, + "meta": {}, + "type": "string" + }, + "companyId": 0, + "companyRoleId": 0, + "createdAt": "string", + "createdBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "deleted": true, + "email": "string", + "firstName": "string", + "id": 0, + "isAdmin": true, + "isClientUser": true, + "isPlaceholderResource": true, + "isServiceAccount": true, + "jobRoles": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "lastLogin": "string", + "lastName": "string", + "lengthOfDay": 0, + "meta": {}, + "skills": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "teams": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "timezone": "string", + "title": "string", + "type": "string", + "updatedAt": "string", + "updatedBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "userCost": 0, + "userRate": 0, + "userRates": { + "additionalProp": { + "amount": 0, + "currency": { + "id": 0, + "meta": {}, + "type": "string" + } + } + }, + "workingHour": { + "id": 0, + "meta": {}, + "type": "string" + }, + "workingHoursId": 0 + } + }, + "workflowStages": { + "additionalProp": { + "color": "string", + "createdAt": "string", + "createdBy": 0, + "deletedAt": "string", + "deletedBy": 0, + "displayOrder": 0, + "id": 0, + "name": "string", + "showCompletedTasks": true, + "taskIds": [0], + "updatedAt": "string", + "updatedBy": 0, + "workflow": { + "id": 0, + "meta": {}, + "type": "string" + } + } + }, + "workflows": { + "additionalProp": { + "companies": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "createdAt": "string", + "createdBy": 0, + "defaultWorkflow": true, + "id": 0, + "lockdown": { + "id": 0, + "meta": {}, + "type": "string" + }, + "name": "string", + "projectIds": [0], + "projectSpecific": true, + "stages": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "status": "string", + "updatedAt": "string", + "updatedBy": 0 + } + } + }, + "tasklist": { + "calculatedDueDate": "string", + "calculatedStartDate": "string", + "createdAt": "string", + "defaultTask": { + "id": 0, + "meta": {}, + "type": "string" + }, + "defaultTaskId": 0, + "description": "string", + "displayOrder": 0, + "icon": "string", + "id": 0, + "isBillable": true, + "isPinned": true, + "isPrivate": true, + "lockdownId": 0, + "milestone": { + "id": 0, + "meta": {}, + "type": "string" + }, + "milestoneId": 0, + "name": "string", + "project": { + "id": 0, + "meta": {}, + "type": "string" + }, + "projectId": 0, + "status": "string", + "tasklistBudget": { + "id": 0, + "meta": {}, + "type": "string" + }, + "updatedAt": "string" + } +} diff --git a/_specs/workflow-200-ok.json b/_specs/workflow-200-ok.json new file mode 100644 index 0000000..a60f9e3 --- /dev/null +++ b/_specs/workflow-200-ok.json @@ -0,0 +1,380 @@ +{ + "included": { + "ProjectPermissions": { + "additionalProp": { + "active": true, + "addExpenses": true, + "addFiles": true, + "addForms": true, + "addLinks": true, + "addMessages": true, + "addMilestones": true, + "addNotebooks": true, + "addProjectUpdate": true, + "addRisks": true, + "addTaskLists": true, + "addTasks": true, + "addTime": true, + "canAccess": true, + "canEditWorkflows": true, + "canManagePeople": true, + "canManageProjectBudget": true, + "canManageProjectMembership": true, + "canManageProjectTemplates": true, + "canManageRates": true, + "canManageSchedule": true, + "canViewForms": true, + "canViewProjectBudget": true, + "canViewProjectMembers": true, + "canViewProjectProfitability": true, + "canViewProjectTemplates": true, + "canViewRates": true, + "canViewSchedule": true, + "canViewWorkflows": true, + "editAllTasks": true, + "inOwnerCompany": true, + "isArchived": true, + "isObserving": true, + "manageCustomFields": true, + "projectAdministrator": true, + "receiveEmailNotifications": true, + "setPrivacy": true, + "viewAllTimeLogs": true, + "viewEstimatedTime": true, + "viewInvoices": true, + "viewLinks": true, + "viewMessagesAndFiles": true, + "viewNotebooks": true, + "viewProjectUpdate": true, + "viewRiskRegister": true, + "viewTasksAndMilestones": true, + "viewTime": true + } + }, + "companies": { + "additionalProp": { + "accounts": 0, + "addressOne": "string", + "addressTwo": "string", + "budgetDistribution": [ + { + "color": "string", + "companyId": 0, + "count": 0, + "from": 0, + "to": 0 + } + ], + "canSeePrivate": true, + "cid": "string", + "city": "string", + "clientManagedBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "clients": 0, + "collaborators": 0, + "companyDomains": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "companyNameUrl": "string", + "companyUpdate": { + "id": 0, + "meta": {}, + "type": "string" + }, + "contacts": 0, + "countryCode": "string", + "createdAt": "string", + "currency": { + "id": 0, + "meta": {}, + "type": "string" + }, + "emailOne": "string", + "emailThree": "string", + "emailTwo": "string", + "fax": "string", + "financialBudgetSummary": { + "totalCapacity": 0, + "totalCapacityUsed": 0 + }, + "id": 0, + "industry": { + "id": 0, + "meta": {}, + "type": "string" + }, + "industryId": 0, + "isOwner": true, + "logoUrl": "string", + "name": "string", + "phone": "string", + "privateNotes": "string", + "privateNotesText": "string", + "profileText": "string", + "profitability": { + "billable": 0, + "billableTime": 0, + "companyCount": 0, + "cost": 0, + "expenses": 0, + "expensesBillableTotal": 0, + "loggedTime": 0, + "nonBillableTime": 0, + "ownerCount": 0, + "profit": 0, + "profitPercentage": 0, + "profitTargetPercentage": 0, + "targetCostsDollars": 0, + "targetProfitDollars": 0 + }, + "rates": [ + { + "createdAt": "string", + "createdByUser": { + "id": 0, + "meta": {}, + "type": "string" + }, + "rate": { + "amount": 0, + "currency": { + "id": 0, + "meta": {}, + "type": "string" + } + }, + "role": { + "id": 0, + "meta": {}, + "type": "string" + }, + "updatedAt": "string", + "updatedByUser": { + "id": 0, + "meta": {}, + "type": "string" + } + } + ], + "state": "string", + "stats": { + "projectCount": 0, + "taskCompleteCount": 0, + "taskCount": 0, + "unreadEmailCount": 0 + }, + "status": "string", + "tags": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "timeBudgetSummary": { + "totalCapacity": 0, + "totalCapacityUsed": 0 + }, + "updatedAt": "string", + "website": "string", + "zip": "string" + } + }, + "projectIntegrations": { + "additionalProp": { + "canAccessBox": true, + "canAccessDropbox": true, + "canAccessGoogleDocs": true, + "canAccessOneDrive": true, + "canAccessOneDriveBusiness": true, + "canAccessSharePoint": true, + "projectId": 0, + "userId": 0 + } + }, + "projects": { + "additionalProp": {} + }, + "stages": { + "additionalProp": { + "color": "string", + "createdAt": "string", + "createdBy": 0, + "deletedAt": "string", + "deletedBy": 0, + "displayOrder": 0, + "id": 0, + "name": "string", + "showCompletedTasks": true, + "taskIds": [0], + "updatedAt": "string", + "updatedBy": 0, + "workflow": { + "id": 0, + "meta": {}, + "type": "string" + } + } + }, + "teams": { + "additionalProp": { + "company": { + "id": 0, + "meta": {}, + "type": "string" + }, + "createdAt": "string", + "createdBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "handle": "string", + "id": 0, + "name": "string", + "teamLogo": "string", + "teamLogoColor": "string", + "teamLogoIcon": "string", + "updatedAt": "string", + "updatedBy": { + "id": 0, + "meta": {}, + "type": "string" + } + } + }, + "users": { + "additionalProp": { + "avatarUrl": "string", + "canAccessPortfolio": true, + "canAddProjects": true, + "canManagePortfolio": true, + "company": { + "id": 0, + "meta": {}, + "type": "string" + }, + "companyId": 0, + "companyRoleId": 0, + "createdAt": "string", + "createdBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "deleted": true, + "email": "string", + "firstName": "string", + "id": 0, + "isAdmin": true, + "isClientUser": true, + "isPlaceholderResource": true, + "isServiceAccount": true, + "jobRoles": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "lastLogin": "string", + "lastName": "string", + "lengthOfDay": 0, + "meta": {}, + "skills": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "teams": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "timezone": "string", + "title": "string", + "type": "string", + "updatedAt": "string", + "updatedBy": { + "id": 0, + "meta": {}, + "type": "string" + }, + "userCost": 0, + "userRate": 0, + "userRates": { + "additionalProp": { + "amount": 0, + "currency": { + "id": 0, + "meta": {}, + "type": "string" + } + } + }, + "workingHour": { + "id": 0, + "meta": {}, + "type": "string" + }, + "workingHoursId": 0 + } + } + }, + "meta": { + "averageSpend": 0, + "data": {}, + "limit": 0, + "nextCursor": "string", + "page": { + "count": 0, + "hasMore": true, + "pageOffset": 0, + "pageSize": 0 + }, + "prevCursor": "string", + "totalCapacity": 0 + }, + "workflow": { + "companies": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "createdAt": "string", + "createdBy": 0, + "defaultWorkflow": true, + "id": 0, + "lockdown": { + "id": 0, + "meta": {}, + "type": "string" + }, + "name": "string", + "projectIds": [0], + "projectSpecific": true, + "stages": [ + { + "id": 0, + "meta": {}, + "type": "string" + } + ], + "status": "string", + "updatedAt": "string", + "updatedBy": 0 + } +} diff --git a/plans/PLAN.md b/plans/PLAN.md index 2840e36..95eb586 100644 --- a/plans/PLAN.md +++ b/plans/PLAN.md @@ -116,11 +116,11 @@ See `STATE_MANAGER.md` for the detailed implementation plan, schema, manager API Teamwork work is split into detailed subphase plans under [`plans/teamwork/`](teamwork/README.md). -#### Phase 5.1 — [Teamwork Foundation](teamwork/5.1-foundation.md) +#### Phase 5.1 — [Teamwork Foundation](teamwork/5.1-foundation.md) ✅ Auth, project config, Teamwork route, project metadata, project links, and shared Teamwork HTTP client. -#### Phase 5.2 — [Pinned Project Task Lists](teamwork/5.2-pinned-project-task-lists.md) +#### Phase 5.2 — [Pinned Project Task Lists](teamwork/5.2-pinned-project-task-lists.md) 🔨 Project-configured Teamwork task lists for recurring project tasks such as code review, meetings, miscellaneous work, project management, and documentation. diff --git a/plans/teamwork/5.1-foundation.md b/plans/teamwork/5.1-foundation.md index 0aecade..20e52e8 100644 --- a/plans/teamwork/5.1-foundation.md +++ b/plans/teamwork/5.1-foundation.md @@ -1,18 +1,20 @@ # 5.1 Teamwork Foundation +Status: Complete. + ## Goal Make WTC aware of the current Teamwork project and user auth state without adding task or timer mutations yet. ## Current Scope -- Store Teamwork API tokens in Bun OS secrets, never YAML. -- Keep the Teamwork site fixed to We The Collective's Teamwork instance. -- Add `teamwork.projectId` to project config. -- Add a Teamwork route with `my-work` and `project` tabs. -- Restore Teamwork route tab state per project directory. -- Fetch and cache Teamwork project metadata by project ID. -- Show project config status, auth status, project links, and project metadata in the Project tab. +- ✅ Store Teamwork API tokens in Bun OS secrets, never YAML. +- ✅ Keep the Teamwork site fixed to We The Collective's Teamwork instance. +- ✅ Add `teamwork.projectId` to project config. +- ✅ Add a Teamwork route with `my-work` and `project` tabs. +- ✅ Restore Teamwork route tab state per project directory. +- ✅ Fetch and cache Teamwork project metadata by project ID. +- ✅ Show project config status, auth status, project links, and project metadata in the Project tab. ## API Shape @@ -28,5 +30,5 @@ Make WTC aware of the current Teamwork project and user auth state without addin ## Open Questions -- Confirm the exact Teamwork v3 project metadata response shape against real data. -- Decide whether the Project tab should include a manual refresh action before task lists are added. +- ✅ Confirm the exact Teamwork project metadata response shape enough for the current display. +- Manual refresh is deferred until real usage shows the automatic load is insufficient. diff --git a/plans/teamwork/5.2-pinned-project-task-lists.md b/plans/teamwork/5.2-pinned-project-task-lists.md index 8e0bebc..f3b4dee 100644 --- a/plans/teamwork/5.2-pinned-project-task-lists.md +++ b/plans/teamwork/5.2-pinned-project-task-lists.md @@ -1,5 +1,7 @@ # 5.2 Pinned Project Task Lists +Status: In progress. Core config, Settings editing, Project tab display, task selection/open, and CLI equivalents exist. Current polish is richer task metadata display. + ## Goal Surface recurring project-level Teamwork tasks that are useful for everyone on a project, especially tasks used for time tracking such as meetings, code reviews, miscellaneous work, project management, and documentation. @@ -36,24 +38,45 @@ Use the configured `name` as the WTC display label. Do not rely on the full Team ## API Shape -- Add `getTeamworkTaskListTasks(taskListId)`. -- The function should fetch tasks for the task list and return the minimal task shape WTC needs first. +- ✅ Add `getTeamworkTaskListTasks(taskListId)`. +- ✅ Fetch tasks for the task list and return the task shape WTC needs. +- Include task metadata when Teamwork provides it: assignees, due date, board column, and priority. +- Verify real Teamwork v3 task/list responses before keeping optional metadata aliases in schemas. - Do not add disk cache initially unless real use shows the list is slow or rate-limited. - Keep endpoint-specific workflow in one `get*` function unless shared behavior emerges. +## Field Verification + +Use `scripts/inspect-teamwork-task-fields.ts` with the stored Teamwork API token to inspect real response shapes before trimming or adding parser fields. + +Known verification IDs: + +- Task `26523243`: blocked board column. +- Task `26751525`: To Do board column, relatively new. +- Task `26751526`: multiple assignees, no board column, priority Low. +- Task `26751560`: belongs to task list `1691926`. +- Task list `1691926`: contains the known verification tasks for assignees, due dates, board columns, and priority. + +Fields to verify: + +- ✅ Assignees: use `assigneeUsers` / `assigneeUserIds` with `include=users,assigneeUsers`; ignore generic `assignees` for display. +- ✅ Due date: use `dueDate`; it is returned as an ISO timestamp and should be normalized to `YYYY-MM-DD`. +- ✅ Board column: use task `workflowStages[].workflowId/stageId`, then fetch `/workflows/{workflowId}.json?include=stages` to resolve stage names. +- ✅ Priority: `priority` is `null` or a string such as `low`. + ## TUI Scope -- Show pinned task lists in the Teamwork Project tab. -- Show tasks grouped under each configured pinned task list. -- Show useful empty states for missing `.wtc.yaml`, missing `teamwork.projectId`, missing `teamwork.pinnedTaskLists`, missing auth token, and API errors. -- Add a TUI/Settings way to add, rename, and remove pinned task lists before treating CLI `pin`/`unpin` as the primary editing path. -- Manual YAML editing is acceptable only as a temporary bridge while the TUI edit flow is being built. +- ✅ Show pinned task lists in the Teamwork Project tab. +- ✅ Show tasks grouped under each configured pinned task list. +- ✅ Show useful empty states for missing `.wtc.yaml`, missing `teamwork.projectId`, missing `teamwork.pinnedTaskLists`, missing auth token, and API errors. +- ✅ Add a TUI/Settings way to add, rename, and remove pinned task lists before treating CLI `pin`/`unpin` as the primary editing path. +- Show compact task metadata: assignee, due date, board column, and priority. ## CLI Scope Pinned task list commands should live under `task-list` because they operate on configured task lists, not a single task. -Add these after pinned task lists can be viewed and edited in the TUI/Settings flow. +These exist after the TUI/Settings flow proved the config shape. ```bash wtc teamwork task-list pinned @@ -62,22 +85,23 @@ wtc teamwork task-list pin 1597639 --name "General Tasks" wtc teamwork task-list unpin 1597639 ``` -- `task-list pinned` reads `.wtc.yaml`, fetches tasks for each configured pinned task list, and prints grouped tasks. -- `task-list pin` writes a new `teamwork.pinnedTaskLists` entry to the nearest `.wtc.yaml`. -- `task-list unpin` removes a configured pinned task list from the nearest `.wtc.yaml`. -- Prefer a non-interactive `--name` flag first; a TTY prompt can come later. +- ✅ `task-list pinned` reads `.wtc.yaml`, fetches tasks for each configured pinned task list, and prints grouped tasks. +- ✅ `task-list pin` writes a new `teamwork.pinnedTaskLists` entry to the nearest `.wtc.yaml`. +- ✅ `task-list unpin` removes a configured pinned task list from the nearest `.wtc.yaml`. +- ✅ Prefer a non-interactive `--name` flag first; a TTY prompt can come later. ## Selection Behavior -- Add reusable task selection behavior only when both pinned tasks and My Work need it, or keep selection local until reuse is real. -- Use `↑` and `↓` for moving between tasks. -- Use `enter` or `ctrl+o` to open a selected task in the browser. +- ✅ Add reusable task selection behavior only when both pinned tasks and My Work need it, or keep selection local until reuse is real. +- ✅ Use `↑` and `↓` for moving between tasks. +- ✅ Use `enter` or `ctrl+o` to open a selected task in the browser. - Leave timer and branch actions as placeholders until their subphases. ## Open Questions -- Which Teamwork endpoint returns tasks for a task list in the shape we need? -- Should pinned task list tasks be cached later, or always fetched fresh? -- Should pinned task list editing live in Settings only, or should the Project tab offer contextual add/remove actions too? +- ✅ Which Teamwork endpoint returns tasks for a task list in the shape we need? +- ✅ Workflow stage name requests cached with 7-day TTL in `src/teamwork/workflow-stages.ts`. +- Pinned task list tasks (not workflows) still fetched fresh; no plan to cache them unless they prove slow. +- Pinned task list editing lives in Settings for now; consider contextual Project tab actions after My Work is built. - Do we eventually need optional task aliases, such as marking which task is the code review task, or can users infer that from task names? -- Should `task-list pin` create `.wtc.yaml` when none exists, or fail and tell users to run `wtc config init` first? +- ✅ `task-list pin` creates `.wtc.yaml` when none exists. diff --git a/scripts/inspect-teamwork-task-fields.ts b/scripts/inspect-teamwork-task-fields.ts new file mode 100644 index 0000000..5b62b54 --- /dev/null +++ b/scripts/inspect-teamwork-task-fields.ts @@ -0,0 +1,269 @@ +import { fetchTeamworkApiJson } from "../src/teamwork/client.ts"; + +const DEFAULT_TASK_IDS = [26523243, 26751525, 26751526] as const; +const DEFAULT_TASK_LIST_IDS = [1691926] as const; +const INCLUDE_QUERY = "include=users,workflowStages,stages,assigneeUsers"; + +type JsonObject = Record; + +interface IncludedSummary { + users: JsonObject[]; + stages: JsonObject[]; + workflowStages: JsonObject[]; +} + +interface TaskFieldSummary { + id: unknown; + name: unknown; + status: unknown; + assignees: unknown; + assigneeUsers: unknown; + assigneeUserIds: unknown; + dueDate: unknown; + originalDueDate: unknown; + sequenceDueDate: unknown; + column: unknown; + workflowStages: unknown; + priority: unknown; +} + +interface TaskEndpointReport { + endpoint: string; + taskKeys: string[]; + task: TaskFieldSummary | null; + included: IncludedSummary; +} + +interface TaskListEndpointReport { + endpoint: string; + tasklistKeys: string[]; + tasklist: JsonObject | null; + includedKeys: string[]; +} + +interface TaskListTasksEndpointReport { + endpoint: string; + taskCount: number; + taskKeys: string[][]; + tasks: TaskFieldSummary[]; + included: IncludedSummary; +} + +interface WorkflowEndpointReport { + endpoint: string; + workflow: JsonObject | null; + includedStages: JsonObject[]; +} + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function getObject(value: JsonObject, key: string): JsonObject | null { + const next = value[key]; + return isObject(next) ? next : null; +} + +function getArray(value: JsonObject, key: string): readonly unknown[] { + const next = value[key]; + return Array.isArray(next) ? next : []; +} + +function summarizeIncludedCollection(value: JsonObject | null): JsonObject[] { + if (!value) return []; + + return Object.values(value) + .filter(isObject) + .map((item) => ({ + id: item.id, + name: item.name, + firstName: item.firstName, + lastName: item.lastName, + title: item.title, + type: item.type, + })); +} + +function summarizeStages(value: JsonObject | null): JsonObject[] { + if (!value) return []; + + return Object.values(value) + .filter(isObject) + .map((item) => ({ + id: item.id, + name: item.name, + color: item.color, + displayOrder: item.displayOrder, + })); +} + +function summarizeIncluded(root: JsonObject): IncludedSummary { + const included = getObject(root, "included"); + + return { + users: summarizeIncludedCollection(included ? getObject(included, "users") : null), + stages: summarizeIncludedCollection(included ? getObject(included, "stages") : null), + workflowStages: summarizeIncludedCollection( + included ? getObject(included, "workflowStages") : null, + ), + }; +} + +function summarizeTask(task: JsonObject): TaskFieldSummary { + return { + id: task.id, + name: task.name ?? task.title ?? task.content, + status: task.status, + assignees: task.assignees, + assigneeUsers: task.assigneeUsers, + assigneeUserIds: task.assigneeUserIds, + dueDate: task.dueDate, + originalDueDate: task.originalDueDate, + sequenceDueDate: task.sequenceDueDate, + column: task.column, + workflowStages: task.workflowStages, + priority: task.priority, + }; +} + +async function inspectTask(taskId: number, include = false): Promise { + const endpoint = `/tasks/${taskId}.json${include ? `?${INCLUDE_QUERY}` : ""}`; + const root = await fetchTeamworkApiJson(endpoint); + if (!isObject(root)) throw new Error(`Unexpected Teamwork response for ${endpoint}.`); + + const task = getObject(root, "task"); + + return { + endpoint, + taskKeys: task ? Object.keys(task).sort() : [], + task: task ? summarizeTask(task) : null, + included: summarizeIncluded(root), + }; +} + +async function inspectTaskList( + taskListId: number, + include = false, +): Promise { + const endpoint = `/tasklists/${taskListId}.json${include ? `?${INCLUDE_QUERY}` : ""}`; + const root = await fetchTeamworkApiJson(endpoint); + if (!isObject(root)) throw new Error(`Unexpected Teamwork response for ${endpoint}.`); + + const tasklist = getObject(root, "tasklist"); + const included = getObject(root, "included"); + + return { + endpoint, + tasklistKeys: tasklist ? Object.keys(tasklist).sort() : [], + tasklist, + includedKeys: included ? Object.keys(included).sort() : [], + }; +} + +async function inspectTaskListTasks( + taskListId: number, + include = false, +): Promise { + const endpoint = `/tasklists/${taskListId}/tasks.json${include ? `?${INCLUDE_QUERY}` : ""}`; + const root = await fetchTeamworkApiJson(endpoint); + if (!isObject(root)) throw new Error(`Unexpected Teamwork response for ${endpoint}.`); + + const tasks = getArray(root, "tasks").filter(isObject); + + return { + endpoint, + taskCount: tasks.length, + taskKeys: tasks.map((task) => Object.keys(task).sort()), + tasks: tasks.map(summarizeTask), + included: summarizeIncluded(root), + }; +} + +async function inspectWorkflow( + workflowId: number, + include = false, +): Promise { + const endpoint = `/workflows/${workflowId}.json${include ? `?include=stages` : ""}`; + const root = await fetchTeamworkApiJson(endpoint); + if (!isObject(root)) throw new Error(`Unexpected Teamwork response for ${endpoint}.`); + + const workflow = getObject(root, "workflow"); + const included = getObject(root, "included"); + + return { + endpoint, + workflow, + includedStages: summarizeStages(included ? getObject(included, "stages") : null), + }; +} + +const DEFAULT_WORKFLOW_IDS = [9] as const; + +function parseIds(value: string | undefined, defaults: readonly number[]): number[] { + if (!value?.trim()) return [...defaults]; + + return value.split(",").map((rawId) => { + const id = Number(rawId.trim()); + if (!Number.isInteger(id) || id <= 0) throw new Error(`Invalid Teamwork ID: ${rawId}`); + return id; + }); +} + +const taskIds = parseIds(Bun.env.WTC_TEAMWORK_INSPECT_TASK_IDS, DEFAULT_TASK_IDS); +const taskListIds = parseIds(Bun.env.WTC_TEAMWORK_INSPECT_TASK_LIST_IDS, DEFAULT_TASK_LIST_IDS); +const workflowIds = parseIds(Bun.env.WTC_TEAMWORK_INSPECT_WORKFLOW_IDS, DEFAULT_WORKFLOW_IDS); + +async function inspectSafely( + label: string, + inspect: () => Promise, +): Promise { + try { + return await inspect(); + } catch (error) { + return { + error: `${label}: ${error instanceof Error ? error.message : "Unknown error"}`, + }; + } +} + +const report = { + workflows: await Promise.all( + workflowIds.map((workflowId) => + inspectSafely(`workflow ${workflowId}`, () => inspectWorkflow(workflowId, true)), + ), + ), + tasks: await Promise.all( + taskIds.map((taskId) => inspectSafely(`task ${taskId}`, () => inspectTask(taskId))), + ), + tasksWithIncludes: await Promise.all( + taskIds.map((taskId) => + inspectSafely(`task ${taskId} with includes`, () => inspectTask(taskId, true)), + ), + ), + taskLists: await Promise.all( + taskListIds.map((taskListId) => + inspectSafely(`task list ${taskListId}`, () => inspectTaskList(taskListId)), + ), + ), + taskListsWithIncludes: await Promise.all( + taskListIds.map((taskListId) => + inspectSafely(`task list ${taskListId} with includes`, () => + inspectTaskList(taskListId, true), + ), + ), + ), + taskListTasks: await Promise.all( + taskListIds.map((taskListId) => + inspectSafely(`task list tasks ${taskListId}`, () => inspectTaskListTasks(taskListId)), + ), + ), + taskListTasksWithIncludes: await Promise.all( + taskListIds.map((taskListId) => + inspectSafely(`task list tasks ${taskListId} with includes`, () => + inspectTaskListTasks(taskListId, true), + ), + ), + ), +}; + +console.log(JSON.stringify(report, null, 2)); diff --git a/src/cli/commands/cache.ts b/src/cli/commands/cache.ts index ecb8c09..be91687 100644 --- a/src/cli/commands/cache.ts +++ b/src/cli/commands/cache.ts @@ -1,5 +1,6 @@ import { clearCache } from "../../state/manager.ts"; +/** Deletes the entire WTC cache directory. */ export async function cacheClean(): Promise { await clearCache(); console.log("Cache cleaned."); diff --git a/src/cli/commands/config.ts b/src/cli/commands/config.ts index 25aec7c..0728979 100644 --- a/src/cli/commands/config.ts +++ b/src/cli/commands/config.ts @@ -8,6 +8,7 @@ import { /** Shared with yargs so accepted CLI providers and handler validation stay in sync. */ export const CONFIG_AUTH_PROVIDERS = ["teamwork"] as const; +/** Supported auth provider names for `wtc config auth`. */ export type ConfigAuthProvider = (typeof CONFIG_AUTH_PROVIDERS)[number]; /** Auth dependency boundary so command tests do not touch the OS secret store. */ @@ -34,6 +35,7 @@ export async function configInit(startDir = process.cwd()): Promise { console.log(`Created project config: ${path}`); } +/** Stores an auth token for a provider in OS secrets. */ export async function configAuthSet( args: { provider: string; token: string | undefined }, actions = teamworkAuthActions, @@ -45,6 +47,7 @@ export async function configAuthSet( console.log(`Configured ${provider} auth.`); } +/** Prints whether auth is configured for a provider (without exposing the token). */ export async function configAuthStatus( args: { provider: string }, actions = teamworkAuthActions, @@ -53,6 +56,7 @@ export async function configAuthStatus( console.log(`${provider}: ${await actions.getTeamworkAuthStatus()}`); } +/** Deletes stored auth for a provider. */ export async function configAuthDelete( args: { provider: string }, actions = teamworkAuthActions, diff --git a/src/cli/commands/teamwork.ts b/src/cli/commands/teamwork.ts index 6acce3a..d302069 100644 --- a/src/cli/commands/teamwork.ts +++ b/src/cli/commands/teamwork.ts @@ -48,6 +48,35 @@ const teamworkTaskOpenActions: TeamworkTaskOpenActions = { openUrlInBrowser, }; +/** + * Formats a task's metadata fields into human-readable text lines for CLI output. + * + * Example output: `["assignee: Marlon Bain", "due: 2026-06-24", "board: To Do", "priority: high"]` + */ +function formatTeamworkTaskMetadata(task: TeamworkTask): string[] { + const metadata: string[] = []; + + if (task.assignees.length === 1) metadata.push(`assignee: ${task.assignees[0]}`); + if (task.assignees.length > 1) metadata.push(`assignees: ${task.assignees.join(", ")}`); + if (task.dueDate) metadata.push(`due: ${task.dueDate}`); + if (task.boardColumn) metadata.push(`board: ${task.boardColumn.name}`); + if (task.priority) metadata.push(`priority: ${task.priority}`); + + return metadata; +} + +/** + * Formats pinned task list output for CLI display. + * + * Example output: + * ``` + * Project config: /repo/.wtc.yaml + * Pinned task lists: + * General Tasks (1597639) + * - Dev | Code Review [active] + * assignee: Marlon Bain | due: 2026-06-24 | board: To Do | priority: high + * ``` + */ export function formatTeamworkTaskListPinnedOutput( result: PinnedTaskListsResult, options: { json: boolean }, @@ -77,12 +106,15 @@ export function formatTeamworkTaskListPinnedOutput( for (const task of taskList.tasks) { lines.push(` - ${task.name}${task.status ? ` [${task.status}]` : ""}`); + const metadata = formatTeamworkTaskMetadata(task); + if (metadata.length) lines.push(` ${metadata.join(" | ")}`); } } return lines.join("\n"); } +/** Prints pinned task lists and their tasks for the current project. */ export async function teamworkTaskListPinned( args: { json: boolean; startDir?: string }, actions = teamworkTaskListPinnedActions, @@ -115,6 +147,7 @@ export async function teamworkTaskListPinned( console.log(formatTeamworkTaskListPinnedOutput(result, { json: args.json })); } +/** Pins a task list by ID in the nearest project config, or updates the display name if already pinned. */ export async function teamworkTaskListPin( args: { taskListId: number; name: string; startDir?: string }, actions = teamworkTaskListConfigActions, @@ -147,6 +180,7 @@ export async function teamworkTaskListPin( console.log(`Pinned Teamwork task list: ${name} (${args.taskListId}) in ${path}`); } +/** Removes a pinned task list from the nearest project config by ID. */ export async function teamworkTaskListUnpin( args: { taskListId: number; startDir?: string }, actions = teamworkTaskListConfigActions, @@ -172,6 +206,7 @@ export async function teamworkTaskListUnpin( console.log(`Unpinned Teamwork task list: ${existing.name} (${existing.id}) from ${path}`); } +/** Opens a Teamwork task in the default browser from a task ID or URL. */ export async function teamworkTaskOpen( args: { task: string }, actions = teamworkTaskOpenActions, diff --git a/src/config/templates.ts b/src/config/templates.ts index 109f047..b552bef 100644 --- a/src/config/templates.ts +++ b/src/config/templates.ts @@ -2,6 +2,17 @@ import type { ProjectConfig, UserConfig } from "./schema.ts"; // Bun's YAML parser does not preserve comments, so config writes use explicit // formatters to keep known setting comments in generated files. + +/** + * Formats user config as YAML with self-documenting comments. + * + * Example output: + * ```yaml + * # WTC user-level configuration. + * version: 1 + * workspaceName: "WTC" + * ``` + */ export function formatUserConfig(config: UserConfig): string { return `# WTC user-level configuration. @@ -13,6 +24,23 @@ workspaceName: ${JSON.stringify(config.workspaceName)} `; } +/** + * Formats project config as YAML with self-documenting comments. + * + * Example output: + * ```yaml + * version: 1 + * project: + * links: + * - name: Figma + * url: https://figma.com/... + * teamwork: + * projectId: "362632" + * pinnedTaskLists: + * - name: General Tasks + * id: 1597639 + * ``` + */ export function formatProjectConfig(config: ProjectConfig): string { const links = config.project.links.length ? ` links:\n${config.project.links diff --git a/src/state/schema.ts b/src/state/schema.ts index df1dd77..419f751 100644 --- a/src/state/schema.ts +++ b/src/state/schema.ts @@ -1,9 +1,12 @@ import { z } from "zod"; +/** All valid top-level route pages in the TUI. */ export const ROUTE_PAGES = ["home", "github", "settings", "teamwork"] as const; +/** TUI top-level page identifier. */ export type RoutePage = (typeof ROUTE_PAGES)[number]; +/** Per-directory persisted TUI state: last-visited route and tab. */ export const TuiStateEntrySchema = z.object({ lastRoute: z.object({ page: z.enum(ROUTE_PAGES).default("home"), @@ -14,8 +17,10 @@ export const TuiStateEntrySchema = z.object({ export type TuiStateEntry = z.infer; +/** A route with page and tab segments. */ export type Route = TuiStateEntry["lastRoute"]; +/** Top-level TUI state file schema (one file, entries keyed by directory path). */ export const TuiStateFileSchema = z.object({ version: z.literal(1), entries: z.record(z.string(), TuiStateEntrySchema), diff --git a/src/teamwork/auth.ts b/src/teamwork/auth.ts index 5c8c5d4..c487cc1 100644 --- a/src/teamwork/auth.ts +++ b/src/teamwork/auth.ts @@ -6,6 +6,7 @@ const TEAMWORK_TOKEN_SECRET_NAME = "teamwork-api-token"; /** Safe-to-display auth state. The token value itself must never be surfaced. */ export type TeamworkAuthStatus = "configured" | "missing"; +/** Returns the stored Teamwork API token, or null when not configured. */ export async function getTeamworkApiToken(): Promise { return Bun.secrets.get({ service: TEAMWORK_SECRET_SERVICE, @@ -13,6 +14,7 @@ export async function getTeamworkApiToken(): Promise { }); } +/** Stores a Teamwork API token in OS secrets. */ export async function setTeamworkApiToken(token: string): Promise { const value = token.trim(); if (!value) throw new Error("Teamwork API token cannot be empty."); @@ -24,6 +26,7 @@ export async function setTeamworkApiToken(token: string): Promise { }); } +/** Deletes the stored Teamwork API token. Returns false when no token existed. */ export async function deleteTeamworkApiToken(): Promise { return Bun.secrets.delete({ service: TEAMWORK_SECRET_SERVICE, @@ -31,6 +34,7 @@ export async function deleteTeamworkApiToken(): Promise { }); } +/** Returns whether a Teamwork API token is configured, without exposing the value. */ export async function getTeamworkAuthStatus(): Promise { return (await getTeamworkApiToken()) ? "configured" : "missing"; } diff --git a/src/teamwork/client.ts b/src/teamwork/client.ts index 1761506..2b8a9b8 100644 --- a/src/teamwork/client.ts +++ b/src/teamwork/client.ts @@ -1,6 +1,7 @@ import { createTeamworkAuthorizationHeader, getTeamworkApiToken } from "./auth.ts"; import { TEAMWORK_API_BASE_URL } from "./consts.ts"; +/** Fetches a Teamwork v3 API endpoint using stored auth and returns the parsed JSON body. */ export async function fetchTeamworkApiJson(path: string, init: RequestInit = {}): Promise { const token = await getTeamworkApiToken(); if (!token) throw new Error("Teamwork API token is missing."); diff --git a/src/teamwork/consts.ts b/src/teamwork/consts.ts index cc22a74..5b50cca 100644 --- a/src/teamwork/consts.ts +++ b/src/teamwork/consts.ts @@ -1,5 +1,6 @@ -// WTC is built for We The Collective's Teamwork instance, so the site is fixed -// instead of becoming user/project config. +/** WTC is built for We The Collective's Teamwork instance, so the site is fixed instead of becoming user/project config. */ export const TEAMWORK_SITE_NAME = "wethecollective"; +/** Human-facing Teamwork site URL. */ export const TEAMWORK_BASE_URL = `https://${TEAMWORK_SITE_NAME}.teamwork.com`; +/** Base URL for the Teamwork v3 API. */ export const TEAMWORK_API_BASE_URL = `${TEAMWORK_BASE_URL}/projects/api/v3`; diff --git a/src/teamwork/project-metadata.ts b/src/teamwork/project-metadata.ts index 8ce9998..9af41e5 100644 --- a/src/teamwork/project-metadata.ts +++ b/src/teamwork/project-metadata.ts @@ -30,6 +30,7 @@ const TeamworkProjectCacheFileSchema = z.object({ type TeamworkProjectCacheFile = z.infer; +/** A Teamwork project resolved from the API. */ export interface TeamworkProjectMetadata { /** Teamwork project ID configured in `.wtc.yaml`. */ id: number; @@ -37,11 +38,13 @@ export interface TeamworkProjectMetadata { name: string; } +/** Result of a project metadata lookup, indicating whether the value was cached or freshly fetched. */ export interface TeamworkProjectMetadataResult { project: TeamworkProjectMetadata; source: "cache" | "network"; } +/** Fetches Teamwork project metadata by project ID, cached for 24 hours. Falls back to stale cache on network error. */ export async function getTeamworkProjectMetadata( projectId: number, ): Promise { diff --git a/src/teamwork/task-list-tasks.ts b/src/teamwork/task-list-tasks.ts index 2b01047..45849eb 100644 --- a/src/teamwork/task-list-tasks.ts +++ b/src/teamwork/task-list-tasks.ts @@ -1,20 +1,80 @@ import { z } from "zod"; import { fetchTeamworkApiJson } from "./client.ts"; +import { getWorkflowStageNames } from "./workflow-stages.ts"; + +const TeamworkIdSchema = z.union([ + z.number().int().nonnegative(), + z.string().regex(/^\d+$/).transform(Number), +]); + +const TeamworkNamedValueSchema = z.union([ + z.string(), + z.looseObject({ + id: TeamworkIdSchema.optional(), + name: z.string().optional(), + title: z.string().optional(), + value: z.string().optional(), + firstName: z.string().optional(), + lastName: z.string().optional(), + }), +]); + +type TeamworkNamedValue = z.infer; + +const TeamworkDateValueSchema = z.union([ + z.string(), + z.looseObject({ + date: z.string().optional(), + value: z.string().optional(), + }), +]); + +type TeamworkDateValue = z.infer; + +const TeamworkIncludedNamedValueSchema = z.looseObject({ + id: TeamworkIdSchema, + name: z.string().optional(), + title: z.string().optional(), + firstName: z.string().optional(), + lastName: z.string().optional(), +}); + +type TeamworkIncludedNamedValue = z.infer; const TeamworkTaskApiSchema = z.object({ - id: z.union([z.number().int().positive(), z.string().regex(/^\d+$/).transform(Number)]), + id: TeamworkIdSchema, name: z.string().optional(), title: z.string().optional(), content: z.string().optional(), - status: z.string().optional(), - url: z.string().optional(), + status: z.string().nullable().optional(), + url: z.string().nullable().optional(), + assigneeUsers: z.array(TeamworkNamedValueSchema).nullable().optional(), + assigneeUserIds: z.array(TeamworkIdSchema).nullable().optional(), + dueDate: TeamworkDateValueSchema.nullable().optional(), + column: TeamworkNamedValueSchema.nullable().optional(), + priority: TeamworkNamedValueSchema.nullable().optional(), + workflowStages: z + .array( + z.looseObject({ + workflowId: TeamworkIdSchema.optional(), + stageId: TeamworkIdSchema.optional(), + }), + ) + .nullable() + .optional(), +}); + +const TeamworkIncludedSchema = z.looseObject({ + users: z.record(z.string(), TeamworkIncludedNamedValueSchema).optional(), }); const TeamworkTaskListTasksResponseSchema = z.object({ tasks: z.array(TeamworkTaskApiSchema), + included: TeamworkIncludedSchema.optional(), }); +/** A normalized Teamwork task with resolved metadata fields. */ export interface TeamworkTask { /** Teamwork task ID. */ id: number; @@ -24,23 +84,121 @@ export interface TeamworkTask { status: string | null; /** Optional browser URL when the API includes it. */ url: string | null; + /** People assigned to the task when Teamwork includes assignee data. */ + assignees: string[]; + /** Due date in a display-safe `YYYY-MM-DD` shape when available. */ + dueDate: string | null; + /** Teamwork board column with name and optional color from the API. */ + boardColumn: { name: string; color: string | null } | null; + /** Teamwork task priority when available. */ + priority: string | null; +} + +function getNamedValue(value: TeamworkNamedValue | null | undefined): string | null { + if (!value) return null; + if (typeof value === "string") return value.trim() || null; + + const combinedName = [value.firstName, value.lastName] + .flatMap((part) => (part?.trim() ? [part.trim()] : [])) + .join(" "); + return value.name?.trim() || value.title?.trim() || value.value?.trim() || combinedName || null; +} + +function getIncludedNamedValue(value: TeamworkIncludedNamedValue | undefined): string | null { + if (!value) return null; + + const combinedName = [value.firstName, value.lastName] + .flatMap((part) => (part?.trim() ? [part.trim()] : [])) + .join(" "); + return value.name?.trim() || combinedName || value.title?.trim() || null; +} + +function getIncludedValueById( + values: Record | undefined, + id: number | null | undefined, +): TeamworkIncludedNamedValue | null { + if (id === null || id === undefined || !values) return null; + return values[id.toString()] ?? Object.values(values).find((value) => value.id === id) ?? null; +} + +function getReferencedName( + value: TeamworkNamedValue | undefined, + included: Record | undefined, +): string | null { + const valueId = value && typeof value !== "string" ? (value.id ?? null) : null; + return ( + getNamedValue(value) ?? + getIncludedNamedValue(getIncludedValueById(included, valueId) ?? undefined) + ); +} + +/** Extracts a Teamwork v3 due date and normalizes `YYYYMMDD` and ISO timestamp strings to `YYYY-MM-DD`. */ +function parseTeamworkDueDate(dueDate: TeamworkDateValue | null | undefined): string | null { + const raw = dueDate ?? undefined; + const extracted = !raw ? null : typeof raw === "string" ? raw : (raw.date ?? raw.value ?? null); + const trimmed = extracted?.trim(); + if (!trimmed) return null; + if (/^\d{8}$/.test(trimmed)) + return `${trimmed.slice(0, 4)}-${trimmed.slice(4, 6)}-${trimmed.slice(6)}`; + if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) return trimmed.slice(0, 10); + return trimmed; } +/** Fetches tasks for a Teamwork task list and resolves workflow stage names, assignees, due dates, and priority. */ export async function getTeamworkTaskListTasks(taskListId: number): Promise { const parsed = TeamworkTaskListTasksResponseSchema.parse( - await fetchTeamworkApiJson(`/tasklists/${taskListId}/tasks.json`), + await fetchTeamworkApiJson(`/tasklists/${taskListId}/tasks.json?include=users,assigneeUsers`), ); + const workflowIds = [ + ...new Set( + parsed.tasks.flatMap( + (task) => + task.workflowStages?.flatMap((stage) => + stage.workflowId && stage.stageId ? [stage.workflowId.toString()] : [], + ) ?? [], + ), + ), + ]; + const stageEntries = new Map(); + for (const workflowId of workflowIds) { + const entries = await getWorkflowStageNames(Number(workflowId)); + for (const [id, entry] of entries) { + stageEntries.set(id, entry); + } + } + const tasks: TeamworkTask[] = []; for (const task of parsed.tasks) { const name = task.name ?? task.title ?? task.content; if (!name) throw new Error("Teamwork task response did not include a task name."); + const assignees = [ + ...new Set([ + ...(task.assigneeUsers?.flatMap((assignee) => { + const name = getReferencedName(assignee, parsed.included?.users); + return name ? [name] : []; + }) ?? []), + ...(task.assigneeUserIds?.flatMap((id) => { + const name = getIncludedNamedValue( + getIncludedValueById(parsed.included?.users, id) ?? undefined, + ); + return name ? [name] : []; + }) ?? []), + ]), + ]; + tasks.push({ id: task.id, name, status: task.status ?? null, url: task.url ?? null, + assignees, + dueDate: parseTeamworkDueDate(task.dueDate), + boardColumn: task.workflowStages?.[0]?.stageId + ? (stageEntries.get(task.workflowStages[0].stageId) ?? null) + : null, + priority: getNamedValue(task.priority), }); } diff --git a/src/teamwork/tasks.ts b/src/teamwork/tasks.ts index 9839d86..f1e391b 100644 --- a/src/teamwork/tasks.ts +++ b/src/teamwork/tasks.ts @@ -1,5 +1,6 @@ import { TEAMWORK_BASE_URL } from "./consts.ts"; +/** A Teamwork task parsed from a user-provided ID or URL. */ export interface TeamworkTaskReference { /** Numeric Teamwork task ID. */ id: number; @@ -7,6 +8,7 @@ export interface TeamworkTaskReference { url: string; } +/** Parses a user-provided task ID (e.g. "12345") or Teamwork URL into a task reference. */ export function getTeamworkTaskReference(value: string): TeamworkTaskReference { const trimmed = value.trim(); if (!trimmed) throw new Error("Teamwork task ID or URL is required."); diff --git a/src/teamwork/workflow-stages.ts b/src/teamwork/workflow-stages.ts new file mode 100644 index 0000000..a8155a2 --- /dev/null +++ b/src/teamwork/workflow-stages.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +import { getCacheDir } from "../state/consts.ts"; +import { fetchTeamworkApiJson } from "./client.ts"; + +const WORKFLOW_STAGES_CACHE_FILE = "teamwork-workflow-stages.json"; +const WORKFLOW_STAGES_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +const StageSchema = z.object({ + id: z.union([z.number(), z.string().regex(/^\d+$/).transform(Number)]), + name: z.string().optional(), + color: z.string().optional(), +}); + +const WorkflowResponseSchema = z.object({ + included: z + .object({ + stages: z.record(z.string(), StageSchema).optional(), + }) + .optional(), +}); + +interface WorkflowStagesCacheEntry { + stages: Record; + cachedAt: number; +} + +interface WorkflowStagesCacheFile { + version: number; + workflows: Record; +} + +/** Fetches workflow stage names and colors from Teamwork, cached on disk for 7 days. */ +export async function getWorkflowStageNames( + workflowId: number, +): Promise> { + const key = workflowId.toString(); + const now = Date.now(); + let cache: WorkflowStagesCacheFile; + let cacheWasUpgraded = false; + + try { + cache = JSON.parse(await Bun.file(`${getCacheDir()}/${WORKFLOW_STAGES_CACHE_FILE}`).text()); + } catch { + cache = { version: 2, workflows: {} }; + } + if (cache.version < 2) { + cache.version = 2; + cacheWasUpgraded = true; + } + + const cached = cache.workflows[key]; + if (cached && cache.version >= 2 && now - cached.cachedAt < WORKFLOW_STAGES_CACHE_TTL_MS) { + if (cacheWasUpgraded) { + await Bun.write( + `${getCacheDir()}/${WORKFLOW_STAGES_CACHE_FILE}`, + `${JSON.stringify(cache, null, 2)}\n`, + ); + } + return new Map(Object.entries(cached.stages).map(([id, entry]) => [Number(id), entry])); + } + + const parsed = WorkflowResponseSchema.parse( + await fetchTeamworkApiJson(`/workflows/${workflowId}.json?include=stages`), + ); + + const stageData: Record = {}; + const stages = new Map(); + for (const stage of Object.values(parsed.included?.stages ?? {})) { + const name = stage.name?.trim(); + if (name) { + const entry = { name, color: stage.color?.trim() || null }; + stageData[stage.id.toString()] = entry; + stages.set(stage.id, entry); + } + } + + cache.workflows[key] = { stages: stageData, cachedAt: now }; + await Bun.write( + `${getCacheDir()}/${WORKFLOW_STAGES_CACHE_FILE}`, + `${JSON.stringify(cache, null, 2)}\n`, + ); + + return stages; +} diff --git a/src/tui/components/forms/dynamic-list.tsx b/src/tui/components/forms/dynamic-list.tsx index 399a249..997bc36 100644 --- a/src/tui/components/forms/dynamic-list.tsx +++ b/src/tui/components/forms/dynamic-list.tsx @@ -3,6 +3,7 @@ import { For, Show, type JSX } from "solid-js"; import { ActionButton } from "./action-button.tsx"; import { tokens } from "../../tokens.ts"; +/** Props for an editable dynamic list (add/remove rows) used in Settings forms. */ export interface DynamicListProps { /** Current rows in the editable list. */ items: readonly T[]; @@ -24,6 +25,7 @@ export interface DynamicListProps { renderItem: (item: T, index: number) => JSX.Element; } +/** An editable list with add and per-row remove actions, used for project links and pinned task lists. */ export function DynamicList(props: DynamicListProps) { return ( diff --git a/src/tui/components/layout/accordion-section.tsx b/src/tui/components/layout/accordion-section.tsx index 65de71a..8c2d017 100644 --- a/src/tui/components/layout/accordion-section.tsx +++ b/src/tui/components/layout/accordion-section.tsx @@ -3,6 +3,7 @@ import { TextAttributes } from "@opentui/core"; import { tokens } from "../../tokens.ts"; +/** Props for a collapsible accordion section used in Settings and other multi-section pages. */ export interface AccordionSectionProps extends ParentProps { /** Section title shown in the collapsible header. */ title: string; @@ -21,6 +22,7 @@ function descriptions(value: AccordionSectionProps["description"]): readonly str return typeof value === "string" ? [value] : value; } +/** A collapsible section with title, description, status, and expandable body content. */ export function AccordionSection(props: AccordionSectionProps) { return ( (); +/** Provides a scroll-into-view helper to child form components for focus scrolling. */ export function ScrollProvider( props: { scrollbox: () => ScrollBoxRenderable | undefined } & ParentProps, ) { @@ -19,6 +20,7 @@ export function ScrollProvider( return {props.children}; } +/** Returns the current page scroll context, or null when no ScrollProvider is ancestor. */ export function usePageScroll(): ScrollContextValue | null { return useContext(ScrollContext) ?? null; } diff --git a/src/tui/components/state-provider.tsx b/src/tui/components/state-provider.tsx index de0214a..107b531 100644 --- a/src/tui/components/state-provider.tsx +++ b/src/tui/components/state-provider.tsx @@ -3,6 +3,7 @@ import { createContext, createSignal, useContext, type ParentProps } from "solid import { saveTuiState } from "../../state/manager.ts"; import type { TuiStateEntry } from "../../state/schema.ts"; +/** TUI state context: provides current state snapshot and a persist-to-disk updater. */ export interface StateContextValue { /** Current state snapshot for the active directory. */ state: TuiStateEntry; @@ -12,6 +13,7 @@ export interface StateContextValue { const StateContext = createContext(); +/** Provides the per-directory TUI state to the component tree. */ export function StateProvider(props: { dir: string; initialState: TuiStateEntry } & ParentProps) { const [state, setState] = createSignal(props.initialState); @@ -29,6 +31,7 @@ export function StateProvider(props: { dir: string; initialState: TuiStateEntry return {props.children}; } +/** Returns the current TUI state context. */ export function useTuiState(): StateContextValue { const value = useContext(StateContext); if (!value) { diff --git a/src/tui/components/status-bar.tsx b/src/tui/components/status-bar.tsx index 41b90f2..8fd8111 100644 --- a/src/tui/components/status-bar.tsx +++ b/src/tui/components/status-bar.tsx @@ -2,6 +2,7 @@ import { createContext, createSignal, useContext, type ParentProps } from "solid import { tokens } from "../tokens.ts"; +/** A keybinding hint shown in the TUI status bar (e.g. `"ctrl+q" + "Quit"`). */ export interface StatusBarHint { key: string; label: string; @@ -13,6 +14,7 @@ interface StatusBarContextValue { const StatusBarContext = createContext(); +/** Provides status bar hints to the component tree, merging global and contextual hints. */ export function StatusBarProvider(props: { globalHints: StatusBarHint[] } & ParentProps) { const [contextualHints, setContextualHints] = createSignal([]); @@ -49,6 +51,7 @@ function StatusBar(props: InternalBarProps) { ); } +/** Returns the status bar context, used by pages to register contextual hints. */ export function useStatusBar(): StatusBarContextValue { const value = useContext(StatusBarContext); if (!value) { diff --git a/src/tui/components/teamwork/task-list.tsx b/src/tui/components/teamwork/task-list.tsx index c32ac20..fc117f1 100644 --- a/src/tui/components/teamwork/task-list.tsx +++ b/src/tui/components/teamwork/task-list.tsx @@ -1,16 +1,31 @@ import { For } from "solid-js"; +import { TextAttributes } from "@opentui/core"; import type { TeamworkTask } from "../../../teamwork/task-list-tasks.ts"; import { tokens } from "../../tokens.ts"; +import { TaskMetadata } from "./task-metadata.tsx"; -export function TaskList(props: { tasks: readonly TeamworkTask[]; emptyMessage: string }) { +/** Renders a list of tasks with name, status, and styled metadata row. Supports keyboard selection highlight. */ +export function TaskList(props: { + taskListId: number; + tasks: readonly TeamworkTask[]; + emptyMessage: string; + selectedTaskId?: number | null; +}) { return props.tasks.length ? ( {(task) => ( - - {task.name} - {task.status ? ` [${task.status}]` : ""} - + + + {props.selectedTaskId === task.id ? "> " : " "} + {task.name} + {task.status ? ` [${task.status}]` : ""} + + + )} ) : ( diff --git a/src/tui/components/teamwork/task-metadata.tsx b/src/tui/components/teamwork/task-metadata.tsx new file mode 100644 index 0000000..cbbb77a --- /dev/null +++ b/src/tui/components/teamwork/task-metadata.tsx @@ -0,0 +1,49 @@ +import { Show } from "solid-js"; +import { t, bold, fg } from "@opentui/core"; + +import type { TeamworkTask } from "../../../teamwork/task-list-tasks.ts"; +import { tokens, palette } from "../../tokens.ts"; + +function priorityColor(priority: string): string { + switch (priority) { + case "urgent": + case "high": + return palette.red; + case "medium": + return palette.yellow75; + case "low": + return palette.green75; + default: + return palette.black50; + } +} + +/** Renders a task's metadata (assignees, due date, board column, priority) as a row of styled inline fields. */ +export function TaskMetadata(props: { task: TeamworkTask }) { + const task = () => props.task; + + return ( + 0 || task().dueDate || task().boardColumn || task().priority} + > + + 0}> + {t`${bold("assignee:")} ${task().assignees.join(", ")}`} + + + {t`${bold("due:")} ${task().dueDate ?? ""}`} + + + {t`${bold("board:")} ${fg(task().boardColumn?.color ?? tokens.textDim)(task().boardColumn?.name ?? "")}`} + + + {t`${bold("priority:")} ${fg(priorityColor(task().priority ?? ""))(task().priority ?? "")}`} + + + + ); +} diff --git a/src/tui/pages/settings.tsx b/src/tui/pages/settings.tsx index 00a89f8..6d418ec 100644 --- a/src/tui/pages/settings.tsx +++ b/src/tui/pages/settings.tsx @@ -390,6 +390,7 @@ export function SettingsPage() { ); } +/** Parses a user-provided teamwork project ID string, returning null when invalid. */ export function parseTeamworkProjectId(value: string): number | null { const trimmed = value.trim(); if (!trimmed) return null; @@ -399,6 +400,7 @@ export function parseTeamworkProjectId(value: string): number | null { return parsed; } +/** Parses a user-provided pinned task list ID string, returning null when invalid. */ export function parsePinnedTaskListId(value: string): number | null { const trimmed = value.trim(); if (!trimmed) return null; @@ -414,6 +416,7 @@ export function parseTeamworkApiTokenInput(value: string): string | null { return trimmed || null; } +/** Builds initial Settings form state from a resolved config. */ export function buildSettingsFormState(config: ResolvedConfig): SettingsFormState { return { user: { @@ -433,6 +436,7 @@ export function buildSettingsFormState(config: ResolvedConfig): SettingsFormStat }; } +/** Compares two focus targets by value (serializes to JSON). */ export function isSettingsFocusTarget( current: SettingsFocusTarget, expected: SettingsFocusTarget, @@ -440,6 +444,7 @@ export function isSettingsFocusTarget( return JSON.stringify(current) === JSON.stringify(expected); } +/** Builds the ordered list of focusable controls based on which accordion sections are expanded. */ export function getSettingsFocusOrder( state: SettingsFormState, expanded: SettingsExpandedSections = DEFAULT_EXPANDED_SECTIONS, @@ -477,6 +482,7 @@ export function getSettingsFocusOrder( return order; } +/** Cycles to the next or previous Settings control in focus order, wrapping around. */ export function getNextSettingsFocus( current: SettingsFocusTarget, state: SettingsFormState, @@ -492,10 +498,12 @@ export function getNextSettingsFocus( return order[nextIndex] ?? FIRST_FOCUS; } +/** Returns the first validation error message, or null when the form is valid. */ export function getSettingsFormError(state: SettingsFormState): string | null { return Object.values(validateSettingsForm(state))[0] ?? null; } +/** Validates all Settings form fields and returns error messages keyed by field path. */ export function validateSettingsForm(state: SettingsFormState): SettingsFormErrors { const errors: SettingsFormErrors = {}; @@ -536,6 +544,7 @@ export function validateSettingsForm(state: SettingsFormState): SettingsFormErro return errors; } +/** Converts validated Settings form state into UserConfig and ProjectConfig objects, dropping blank dynamic rows. */ export function applySettingsFormState(state: SettingsFormState): { user: UserConfig; project: ProjectConfig; diff --git a/src/tui/pages/settings/pinned-task-lists-section.tsx b/src/tui/pages/settings/pinned-task-lists-section.tsx index 53a1c20..9a8acf5 100644 --- a/src/tui/pages/settings/pinned-task-lists-section.tsx +++ b/src/tui/pages/settings/pinned-task-lists-section.tsx @@ -8,6 +8,7 @@ import type { SettingsFormState, } from "./types.ts"; +/** Settings section for editable pinned task lists (add/remove rows). */ export function PinnedTaskListsSection(props: { form: SettingsFormState; expanded: boolean; diff --git a/src/tui/pages/settings/project-config-section.tsx b/src/tui/pages/settings/project-config-section.tsx index b71a547..0e8b0f8 100644 --- a/src/tui/pages/settings/project-config-section.tsx +++ b/src/tui/pages/settings/project-config-section.tsx @@ -2,6 +2,7 @@ import { TextField } from "../../components/forms/text-field.tsx"; import { AccordionSection } from "../../components/layout/accordion-section.tsx"; import type { SettingsFocusTarget, SettingsFormErrors, SettingsFormState } from "./types.ts"; +/** Settings section for project-level config (teamwork project ID). */ export function ProjectConfigSection(props: { form: SettingsFormState; projectConfigPath: string | null | undefined; diff --git a/src/tui/pages/settings/project-links-section.tsx b/src/tui/pages/settings/project-links-section.tsx index 881fa07..cfee330 100644 --- a/src/tui/pages/settings/project-links-section.tsx +++ b/src/tui/pages/settings/project-links-section.tsx @@ -8,6 +8,7 @@ import type { SettingsFormState, } from "./types.ts"; +/** Settings section for editable project links (add/remove rows). */ export function ProjectLinksSection(props: { form: SettingsFormState; expanded: boolean; diff --git a/src/tui/pages/settings/types.ts b/src/tui/pages/settings/types.ts index 726ea81..9e52499 100644 --- a/src/tui/pages/settings/types.ts +++ b/src/tui/pages/settings/types.ts @@ -1,8 +1,10 @@ +/** Unsaved form state for a project link row. */ export interface ProjectLinkFormState { name: string; url: string; } +/** Unsaved form state for a pinned task list row. */ export interface PinnedTaskListFormState { name: string; id: string; @@ -23,8 +25,10 @@ export interface SettingsFormState { }; } +/** Settings validation errors keyed by field path (e.g. `"projectLinks.0.name"`). */ export type SettingsFormErrors = Record; +/** A focusable control on the Settings page. */ export type SettingsFocusTarget = | { type: "field"; name: "workspaceName" | "teamworkApiToken" | "teamworkProjectId" } | { type: "projectLink"; index: number; field: "name" | "url" } @@ -37,6 +41,7 @@ export type SettingsFocusTarget = } | { type: "action"; name: "save" | "reload" }; +/** Which accordion sections are expanded on the Settings page. */ export interface SettingsExpandedSections { user: boolean; project: boolean; diff --git a/src/tui/pages/settings/user-config-section.tsx b/src/tui/pages/settings/user-config-section.tsx index 8577bd4..9cfa789 100644 --- a/src/tui/pages/settings/user-config-section.tsx +++ b/src/tui/pages/settings/user-config-section.tsx @@ -4,6 +4,7 @@ import { AccordionSection } from "../../components/layout/accordion-section.tsx" import { tokens } from "../../tokens.ts"; import type { SettingsFocusTarget, SettingsFormState } from "./types.ts"; +/** Settings section for user-level config (workspace name, Teamwork auth). */ export function UserConfigSection(props: { form: SettingsFormState; userConfigPath: string | undefined; diff --git a/src/tui/pages/teamwork.tsx b/src/tui/pages/teamwork.tsx index 9d7d935..43ac5a5 100644 --- a/src/tui/pages/teamwork.tsx +++ b/src/tui/pages/teamwork.tsx @@ -1,4 +1,4 @@ -import { For, Match, onCleanup, onMount, Switch } from "solid-js"; +import { createEffect, For, Match, onCleanup, Switch } from "solid-js"; import { TextAttributes } from "@opentui/core"; import { useBindings } from "@opentui/keymap/solid"; @@ -11,6 +11,7 @@ import { ProjectTab } from "./teamwork/project-tab.tsx"; const TEAMWORK_TABS = ["my-work", "project"] as const; +/** Valid Teamwork page tab identifiers. */ export type TeamworkTab = (typeof TEAMWORK_TABS)[number]; const TABS = [ @@ -18,6 +19,7 @@ const TABS = [ { id: "project", label: "Project" }, ] as const; +/** Cycles to the next or previous Teamwork tab, wrapping around. */ export function getNextTeamworkTab(current: TeamworkTab, direction: 1 | -1): TeamworkTab { const currentIndex = TABS.findIndex((tab) => tab.id === current); const nextIndex = (currentIndex + direction + TABS.length) % TABS.length; @@ -29,6 +31,7 @@ function isValidTab(tab: string): tab is TeamworkTab { return TEAMWORK_TABS.includes(tab as TeamworkTab); } +/** Teamwork route page with My Work and Project tabs, restoring the last active tab from state. */ export function TeamworkPage(props: { activeTab: string; onTabChange: (tab: TeamworkTab) => void; @@ -60,8 +63,16 @@ export function TeamworkPage(props: { ], })); - onMount(() => { - setHints([{ key: "ctrl+←/→", label: "tabs" }]); + createEffect(() => { + setHints( + activeTab() === "project" + ? [ + { key: "ctrl+←/→", label: "tabs" }, + { key: "↑/↓", label: "tasks" }, + { key: "enter/ctrl+o", label: "open" }, + ] + : [{ key: "ctrl+←/→", label: "tabs" }], + ); }); onCleanup(() => setHints([])); diff --git a/src/tui/pages/teamwork/my-work-tab.tsx b/src/tui/pages/teamwork/my-work-tab.tsx index ec019db..272ae72 100644 --- a/src/tui/pages/teamwork/my-work-tab.tsx +++ b/src/tui/pages/teamwork/my-work-tab.tsx @@ -1,6 +1,7 @@ import { Section } from "../../components/layout/section.tsx"; import { tokens } from "../../tokens.ts"; +/** Placeholder tab for global Teamwork tasks assigned to the current user. */ export function MyWorkTab() { return (
(null); const [teamworkAuthStatus, setTeamworkAuthStatus] = createSignal("missing"); @@ -27,12 +38,33 @@ export function ProjectTab() { null, ); const [pinnedTaskLists, setPinnedTaskLists] = createSignal([]); + const [selectedTask, setSelectedTask] = createSignal(null); const [projectMessage, setProjectMessage] = createSignal("Loading project context..."); + const scroll = usePageScroll(); + + createEffect(() => { + const sel = selectedTask(); + if (sel && scroll) { + scroll.scrollChildIntoView(`task-${sel.taskListId}-${sel.taskId}`); + } + }); + + const selectedTeamworkTask = () => { + const selected = selectedTask(); + if (!selected) return null; + + return ( + pinnedTaskLists() + .find((taskList) => taskList.id === selected.taskListId) + ?.tasks.find((task) => task.id === selected.taskId) ?? null + ); + }; const loadProjectContext = async () => { setProjectMessage("Loading project context..."); setProjectMetadata(null); setPinnedTaskLists([]); + setSelectedTask(null); try { const config = await loadResolvedConfig(process.cwd()); @@ -52,6 +84,13 @@ export function ProjectTab() { return; } + if (authStatus === "missing") { + setProjectMessage( + "Teamwork auth not configured. Use Settings or `wtc config auth set` to add your API token.", + ); + return; + } + const metadata = await getTeamworkProjectMetadata(projectId); setProjectMetadata(metadata); @@ -75,6 +114,7 @@ export function ProjectTab() { } setPinnedTaskLists(nextPinnedTaskLists); + setSelectedTask(getNextPinnedTaskSelection(nextPinnedTaskLists, selectedTask(), 1)); setProjectMessage( metadata.source === "cache" ? "Using cached Teamwork project metadata." @@ -85,6 +125,56 @@ export function ProjectTab() { } }; + const openSelectedTask = async () => { + const task = selectedTeamworkTask(); + if (!task) { + setProjectMessage("No pinned task selected."); + return; + } + + const url = task.url ?? getTeamworkTaskReference(task.id.toString()).url; + + try { + await openUrlInBrowser(url); + setProjectMessage(`Opened Teamwork task: ${task.name}`); + } catch (error) { + setProjectMessage(error instanceof Error ? error.message : "Failed to open Teamwork task."); + } + }; + + useBindings(() => ({ + bindings: [ + { + key: "down", + desc: "Next pinned Teamwork task", + group: "Teamwork", + cmd: () => { + setSelectedTask((current) => getNextPinnedTaskSelection(pinnedTaskLists(), current, 1)); + }, + }, + { + key: "up", + desc: "Previous pinned Teamwork task", + group: "Teamwork", + cmd: () => { + setSelectedTask((current) => getNextPinnedTaskSelection(pinnedTaskLists(), current, -1)); + }, + }, + { + key: "return", + desc: "Open pinned Teamwork task", + group: "Teamwork", + cmd: openSelectedTask, + }, + { + key: "ctrl+o", + desc: "Open pinned Teamwork task", + group: "Teamwork", + cmd: openSelectedTask, + }, + ], + })); + onMount(() => { void loadProjectContext(); }); @@ -145,7 +235,14 @@ export function ProjectTab() { {taskList.message ? ( {taskList.message} ) : ( - + )} )} @@ -158,3 +255,39 @@ export function ProjectTab() {
); } + +interface PinnedTaskSelectionSource { + id: number; + tasks: readonly { id: number }[]; +} + +/** Flattens all tasks across pinned task lists into a flat ordered selection array for keyboard navigation. */ +export function getPinnedTaskSelectionOrder( + taskLists: readonly PinnedTaskSelectionSource[], +): PinnedTaskSelection[] { + return taskLists.flatMap((taskList) => + taskList.tasks.map((task) => ({ taskListId: taskList.id, taskId: task.id })), + ); +} + +/** Cycles to the next or previous pinned task across all task lists, wrapping around. */ +export function getNextPinnedTaskSelection( + taskLists: readonly PinnedTaskSelectionSource[], + current: PinnedTaskSelection | null, + direction: 1 | -1, +): PinnedTaskSelection | null { + const order = getPinnedTaskSelectionOrder(taskLists); + if (!order.length) return null; + + const currentIndex = current + ? order.findIndex( + (selection) => + selection.taskListId === current.taskListId && selection.taskId === current.taskId, + ) + : -1; + const fallbackIndex = direction === 1 ? 0 : order.length - 1; + const nextIndex = + currentIndex === -1 ? fallbackIndex : (currentIndex + direction + order.length) % order.length; + + return order[nextIndex] ?? null; +} diff --git a/src/utils/browser.ts b/src/utils/browser.ts index 04e2398..59c5588 100644 --- a/src/utils/browser.ts +++ b/src/utils/browser.ts @@ -1,3 +1,4 @@ +/** Opens a URL in the default system browser (macOS `open`, Linux `xdg-open`, Windows `start`). */ export async function openUrlInBrowser(url: string): Promise { const command = process.platform === "darwin" diff --git a/tests/cli/commands/teamwork.test.ts b/tests/cli/commands/teamwork.test.ts index 06e6be5..dda2c63 100644 --- a/tests/cli/commands/teamwork.test.ts +++ b/tests/cli/commands/teamwork.test.ts @@ -29,8 +29,26 @@ const resolvedConfig: ResolvedConfig = { }; const tasks: TeamworkTask[] = [ - { id: 1, name: "Dev | Code Review", status: "active", url: null }, - { id: 2, name: "General | Meeting", status: null, url: null }, + { + id: 1, + name: "Dev | Code Review", + status: "active", + url: null, + assignees: ["Marlon Bain"], + dueDate: "2026-06-24", + boardColumn: { name: "To Do", color: null }, + priority: "high", + }, + { + id: 2, + name: "General | Meeting", + status: null, + url: null, + assignees: [], + dueDate: null, + boardColumn: null, + priority: null, + }, ]; const originalLog = console.log; @@ -61,6 +79,7 @@ describe("teamwork command", () => { Pinned task lists: General Tasks (1597639) - Dev | Code Review [active] + assignee: Marlon Bain | due: 2026-06-24 | board: To Do | priority: high - General | Meeting`); }); diff --git a/tests/helpers/teamwork.ts b/tests/helpers/teamwork.ts new file mode 100644 index 0000000..6e88905 --- /dev/null +++ b/tests/helpers/teamwork.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** Factory for `mock.module("../../src/teamwork/auth.ts", ...)`. */ +export function mockTeamworkAuthModule() { + return { + createTeamworkAuthorizationHeader(token: string) { + return `Basic ${btoa(`${token}:password`)}`; + }, + deleteTeamworkApiToken: async () => true, + getTeamworkApiToken: async () => "token-123", + getTeamworkAuthStatus: async () => "configured", + setTeamworkApiToken: async () => {}, + }; +} + +/** Wraps a URL + headers-aware handler into a `globalThis.fetch`-compatible mock. */ +export function createMockFetch( + handler: (url: string, init: RequestInit | undefined) => Response | Promise, +) { + return Object.assign( + async (input: Parameters[0], init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + return handler(url, init); + }, + { preconnect: undefined }, + ) as unknown as typeof fetch; +} + +/** Sets `WTC_CACHE_DIR` to a temp directory before each test and cleans up after. */ +export function useTempCacheDir() { + let tempCacheDir: string; + const originalCacheDir = process.env.WTC_CACHE_DIR; + + beforeEach(() => { + tempCacheDir = mkdtempSync(join(tmpdir(), "wtc-test-")); + process.env.WTC_CACHE_DIR = tempCacheDir; + }); + + afterEach(() => { + if (originalCacheDir === undefined) { + delete process.env.WTC_CACHE_DIR; + } else { + process.env.WTC_CACHE_DIR = originalCacheDir; + } + rmSync(tempCacheDir, { recursive: true, force: true }); + }); +} diff --git a/tests/teamwork/project-metadata.test.ts b/tests/teamwork/project-metadata.test.ts index 67c8782..349f332 100644 --- a/tests/teamwork/project-metadata.test.ts +++ b/tests/teamwork/project-metadata.test.ts @@ -1,53 +1,35 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { rm } from "node:fs/promises"; +import { describe, expect, mock, test, afterEach } from "bun:test"; -mock.module("../../src/teamwork/auth.ts", () => ({ - createTeamworkAuthorizationHeader(token: string) { - return `Basic ${btoa(`${token}:password`)}`; - }, - deleteTeamworkApiToken: async () => true, - getTeamworkApiToken: async () => "token-123", - getTeamworkAuthStatus: async () => "configured", - setTeamworkApiToken: async () => {}, -})); +import { createMockFetch, mockTeamworkAuthModule, useTempCacheDir } from "../helpers/teamwork.ts"; +import { TEAMWORK_API_BASE_URL } from "../../src/teamwork/consts.ts"; + +mock.module("../../src/teamwork/auth.ts", mockTeamworkAuthModule); const { createTeamworkAuthorizationHeader } = await import("../../src/teamwork/auth.ts"); const { getTeamworkProjectMetadata } = await import("../../src/teamwork/project-metadata.ts"); -import { TEAMWORK_API_BASE_URL } from "../../src/teamwork/consts.ts"; -const TEST_CACHE = `/tmp/wtc-teamwork-project-tests-${process.pid}`; const originalFetch = globalThis.fetch; describe("teamwork project metadata", () => { - beforeEach(() => { - process.env.WTC_CACHE_DIR = TEST_CACHE; - }); + useTempCacheDir(); - afterEach(async () => { + afterEach(() => { globalThis.fetch = originalFetch; - delete process.env.WTC_CACHE_DIR; - await rm(TEST_CACHE, { recursive: true, force: true }); }); test("gets project metadata with Teamwork auth", async () => { let requestedUrl = ""; let authorization = ""; - globalThis.fetch = Object.assign( - async (input: Parameters[0], init?: Parameters[1]) => { - requestedUrl = input instanceof Request ? input.url : String(input); - authorization = new Headers(init?.headers).get("Authorization") ?? ""; + globalThis.fetch = createMockFetch((url, init) => { + requestedUrl = url; + authorization = new Headers(init?.headers).get("Authorization") ?? ""; - return new Response( - JSON.stringify({ project: { id: "12345", name: "Website Redesign" } }), - { - status: 200, - headers: { "Content-Type": "application/json" }, - }, - ); - }, - { preconnect: originalFetch.preconnect }, - ); + return new Response(JSON.stringify({ project: { id: "12345", name: "Website Redesign" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); const result = await getTeamworkProjectMetadata(12345); expect(result).toEqual({ @@ -61,17 +43,14 @@ describe("teamwork project metadata", () => { test("returns fresh cached project metadata without fetching", async () => { let fetchCount = 0; - globalThis.fetch = Object.assign( - async () => { - fetchCount += 1; + globalThis.fetch = createMockFetch(() => { + fetchCount += 1; - return new Response(JSON.stringify({ project: { id: 12345, name: "Website Redesign" } }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - }, - { preconnect: originalFetch.preconnect }, - ); + return new Response(JSON.stringify({ project: { id: 12345, name: "Website Redesign" } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); const first = await getTeamworkProjectMetadata(12345); const second = await getTeamworkProjectMetadata(12345); diff --git a/tests/teamwork/task-list-tasks.test.ts b/tests/teamwork/task-list-tasks.test.ts index 5984770..33a291f 100644 --- a/tests/teamwork/task-list-tasks.test.ts +++ b/tests/teamwork/task-list-tasks.test.ts @@ -1,58 +1,133 @@ -import { afterEach, describe, expect, mock, test } from "bun:test"; - -mock.module("../../src/teamwork/auth.ts", () => ({ - createTeamworkAuthorizationHeader(token: string) { - return `Basic ${btoa(`${token}:password`)}`; - }, - deleteTeamworkApiToken: async () => true, - getTeamworkApiToken: async () => "token-123", - getTeamworkAuthStatus: async () => "configured", - setTeamworkApiToken: async () => {}, -})); +import { describe, expect, mock, test, afterEach } from "bun:test"; + +import { createMockFetch, mockTeamworkAuthModule, useTempCacheDir } from "../helpers/teamwork.ts"; +import { TEAMWORK_API_BASE_URL } from "../../src/teamwork/consts.ts"; + +mock.module("../../src/teamwork/auth.ts", mockTeamworkAuthModule); const { createTeamworkAuthorizationHeader } = await import("../../src/teamwork/auth.ts"); const { getTeamworkTaskListTasks } = await import("../../src/teamwork/task-list-tasks.ts"); -import { TEAMWORK_API_BASE_URL } from "../../src/teamwork/consts.ts"; const originalFetch = globalThis.fetch; describe("teamwork task list tasks", () => { + useTempCacheDir(); + afterEach(() => { globalThis.fetch = originalFetch; }); test("gets tasks for a Teamwork task list", async () => { - let requestedUrl = ""; + const requestedUrls: string[] = []; let authorization = ""; - globalThis.fetch = Object.assign( - async (input: Parameters[0], init?: Parameters[1]) => { - requestedUrl = input instanceof Request ? input.url : String(input); - authorization = new Headers(init?.headers).get("Authorization") ?? ""; + globalThis.fetch = createMockFetch((url, init) => { + requestedUrls.push(url); + authorization = new Headers(init?.headers).get("Authorization") ?? ""; + if (url.endsWith("/workflows/9.json?include=stages")) { return new Response( JSON.stringify({ - tasks: [ - { id: "1", name: "Dev | Code Review", status: "active" }, - { id: 2, content: "General | Meeting" }, - ], + included: { + stages: { + "5": { id: 5, name: "Blocked", color: "#e40526" }, + "6": { id: 6, name: "To Do", color: "#8599f8" }, + }, + }, }), { status: 200, headers: { "Content-Type": "application/json" }, }, ); - }, - { preconnect: originalFetch.preconnect }, - ); + } + + return new Response( + JSON.stringify({ + included: { + users: { + "7": { id: 7, firstName: "Alex", lastName: "Lee" }, + "8": { id: 8, firstName: "Sam", lastName: "Jones" }, + "9": { id: 9, firstName: "Marlon", lastName: "Bain" }, + }, + }, + tasks: [ + { + id: "1", + name: "Dev | Code Review", + status: "active", + assigneeUsers: [{ id: 9, type: "users" }], + dueDate: "2026-06-24T00:00:00Z", + workflowStages: [{ workflowId: 9, stageId: 6 }], + priority: "high", + }, + { + id: 2, + content: "General | Meeting", + assigneeUsers: [{ id: 7, type: "users" }], + assigneeUserIds: [8], + dueDate: { date: "2026-06-25" }, + workflowStages: [{ workflowId: 9, stageId: 5 }], + priority: { name: "medium" }, + }, + { + id: 3, + content: "General | Miscellaneous", + status: null, + assigneeUsers: null, + assigneeUserIds: null, + dueDate: null, + column: null, + priority: null, + workflowStages: null, + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }); const tasks = await getTeamworkTaskListTasks(1597639); expect(tasks).toEqual([ - { id: 1, name: "Dev | Code Review", status: "active", url: null }, - { id: 2, name: "General | Meeting", status: null, url: null }, + { + id: 1, + name: "Dev | Code Review", + status: "active", + url: null, + assignees: ["Marlon Bain"], + dueDate: "2026-06-24", + boardColumn: { name: "To Do", color: "#8599f8" }, + priority: "high", + }, + { + id: 2, + name: "General | Meeting", + status: null, + url: null, + assignees: ["Alex Lee", "Sam Jones"], + dueDate: "2026-06-25", + boardColumn: { name: "Blocked", color: "#e40526" }, + priority: "medium", + }, + { + id: 3, + name: "General | Miscellaneous", + status: null, + url: null, + assignees: [], + dueDate: null, + boardColumn: null, + priority: null, + }, + ]); + expect(requestedUrls).toEqual([ + `${TEAMWORK_API_BASE_URL}/tasklists/1597639/tasks.json?include=users,assigneeUsers`, + `${TEAMWORK_API_BASE_URL}/workflows/9.json?include=stages`, ]); - expect(requestedUrl).toBe(`${TEAMWORK_API_BASE_URL}/tasklists/1597639/tasks.json`); expect(authorization).toBe(createTeamworkAuthorizationHeader("token-123")); }); }); diff --git a/tests/teamwork/workflow-stages.test.ts b/tests/teamwork/workflow-stages.test.ts new file mode 100644 index 0000000..330440c --- /dev/null +++ b/tests/teamwork/workflow-stages.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; + +import { createMockFetch, mockTeamworkAuthModule, useTempCacheDir } from "../helpers/teamwork.ts"; +import { getCacheDir } from "../../src/state/consts.ts"; + +mock.module("../../src/teamwork/auth.ts", mockTeamworkAuthModule); + +const { getWorkflowStageNames } = await import("../../src/teamwork/workflow-stages.ts"); + +const originalFetch = globalThis.fetch; + +describe("teamwork workflow stages", () => { + useTempCacheDir(); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("returns fresh cached workflow stages without fetching", async () => { + let fetchCount = 0; + + globalThis.fetch = createMockFetch(() => { + fetchCount += 1; + + return new Response( + JSON.stringify({ + included: { + stages: { + "6": { id: 6, name: "To Do", color: "#8599f8" }, + }, + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }); + + const first = await getWorkflowStageNames(9); + const second = await getWorkflowStageNames(9); + + expect(Array.from(first.entries())).toEqual([[6, { name: "To Do", color: "#8599f8" }]]); + expect(Array.from(second.entries())).toEqual([[6, { name: "To Do", color: "#8599f8" }]]); + expect(fetchCount).toBe(1); + }); + + test("refetches workflow stages when cache is stale", async () => { + await Bun.write( + `${getCacheDir()}/teamwork-workflow-stages.json`, + `${JSON.stringify( + { + version: 2, + workflows: { + "9": { + stages: { "6": { name: "Old Stage", color: "#000000" } }, + cachedAt: 0, + }, + }, + }, + null, + 2, + )}\n`, + ); + + let fetchCount = 0; + globalThis.fetch = createMockFetch(() => { + fetchCount += 1; + + return new Response( + JSON.stringify({ + included: { + stages: { + "6": { id: 6, name: "Updated Stage", color: "#ffffff" }, + }, + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + }); + + const stages = await getWorkflowStageNames(9); + + expect(Array.from(stages.entries())).toEqual([ + [6, { name: "Updated Stage", color: "#ffffff" }], + ]); + expect(fetchCount).toBe(1); + }); +}); diff --git a/tests/tui/teamwork.test.ts b/tests/tui/teamwork.test.ts index ff37e54..8869a52 100644 --- a/tests/tui/teamwork.test.ts +++ b/tests/tui/teamwork.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test"; import { getNextTeamworkTab } from "../../src/tui/pages/teamwork.tsx"; +import { + getNextPinnedTaskSelection, + getPinnedTaskSelectionOrder, + type PinnedTaskSelection, +} from "../../src/tui/pages/teamwork/project-tab.tsx"; describe("teamwork page helpers", () => { test("cycles Teamwork tabs", () => { @@ -8,4 +13,41 @@ describe("teamwork page helpers", () => { expect(getNextTeamworkTab("project", 1)).toBe("my-work"); expect(getNextTeamworkTab("project", -1)).toBe("my-work"); }); + + test("builds pinned task selection order", () => { + expect( + getPinnedTaskSelectionOrder([ + { id: 10, tasks: [{ id: 1 }, { id: 2 }] }, + { id: 20, tasks: [{ id: 3 }] }, + ]), + ).toEqual([ + { taskListId: 10, taskId: 1 }, + { taskListId: 10, taskId: 2 }, + { taskListId: 20, taskId: 3 }, + ]); + }); + + test("cycles pinned task selection", () => { + const taskLists = [ + { id: 10, tasks: [{ id: 1 }, { id: 2 }] }, + { id: 20, tasks: [{ id: 3 }] }, + ]; + const current: PinnedTaskSelection = { taskListId: 10, taskId: 2 }; + + expect(getNextPinnedTaskSelection(taskLists, null, 1)).toEqual({ taskListId: 10, taskId: 1 }); + expect(getNextPinnedTaskSelection(taskLists, null, -1)).toEqual({ taskListId: 20, taskId: 3 }); + expect(getNextPinnedTaskSelection(taskLists, current, 1)).toEqual({ + taskListId: 20, + taskId: 3, + }); + expect(getNextPinnedTaskSelection(taskLists, current, -1)).toEqual({ + taskListId: 10, + taskId: 1, + }); + expect(getNextPinnedTaskSelection(taskLists, { taskListId: 99, taskId: 99 }, 1)).toEqual({ + taskListId: 10, + taskId: 1, + }); + expect(getNextPinnedTaskSelection([], current, 1)).toBeNull(); + }); });