@baack-software/baack-ts-tasks 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +158 -2
  3. package/dist/activity.d.ts +20 -0
  4. package/dist/activity.d.ts.map +1 -0
  5. package/dist/activity.js +75 -0
  6. package/dist/activity.js.map +1 -0
  7. package/dist/blockers.d.ts +28 -0
  8. package/dist/blockers.d.ts.map +1 -0
  9. package/dist/blockers.js +134 -0
  10. package/dist/blockers.js.map +1 -0
  11. package/dist/conflicts.d.ts +50 -0
  12. package/dist/conflicts.d.ts.map +1 -0
  13. package/dist/conflicts.js +73 -0
  14. package/dist/conflicts.js.map +1 -0
  15. package/dist/draft.d.ts +45 -0
  16. package/dist/draft.d.ts.map +1 -0
  17. package/dist/draft.js +155 -0
  18. package/dist/draft.js.map +1 -0
  19. package/dist/errors.d.ts +30 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +37 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/index.d.ts +11 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +11 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/react/TaskActivity.d.ts +15 -0
  28. package/dist/react/TaskActivity.d.ts.map +1 -0
  29. package/dist/react/TaskActivity.js +60 -0
  30. package/dist/react/TaskActivity.js.map +1 -0
  31. package/dist/react/TaskBoard.d.ts +38 -0
  32. package/dist/react/TaskBoard.d.ts.map +1 -0
  33. package/dist/react/TaskBoard.js +85 -0
  34. package/dist/react/TaskBoard.js.map +1 -0
  35. package/dist/react/TaskConflicts.d.ts +26 -0
  36. package/dist/react/TaskConflicts.d.ts.map +1 -0
  37. package/dist/react/TaskConflicts.js +87 -0
  38. package/dist/react/TaskConflicts.js.map +1 -0
  39. package/dist/react/TaskCreateForm.d.ts +13 -0
  40. package/dist/react/TaskCreateForm.d.ts.map +1 -0
  41. package/dist/react/TaskCreateForm.js +68 -0
  42. package/dist/react/TaskCreateForm.js.map +1 -0
  43. package/dist/react/TaskEditor.d.ts +33 -0
  44. package/dist/react/TaskEditor.d.ts.map +1 -0
  45. package/dist/react/TaskEditor.js +172 -0
  46. package/dist/react/TaskEditor.js.map +1 -0
  47. package/dist/react/TaskFilters.d.ts +15 -0
  48. package/dist/react/TaskFilters.d.ts.map +1 -0
  49. package/dist/react/TaskFilters.js +48 -0
  50. package/dist/react/TaskFilters.js.map +1 -0
  51. package/dist/react/TaskView.d.ts +30 -0
  52. package/dist/react/TaskView.d.ts.map +1 -0
  53. package/dist/react/TaskView.js +108 -0
  54. package/dist/react/TaskView.js.map +1 -0
  55. package/dist/react/TasksAbout.d.ts +24 -0
  56. package/dist/react/TasksAbout.d.ts.map +1 -0
  57. package/dist/react/TasksAbout.js +82 -0
  58. package/dist/react/TasksAbout.js.map +1 -0
  59. package/dist/react/copy.d.ts +154 -0
  60. package/dist/react/copy.d.ts.map +1 -0
  61. package/dist/react/copy.js +173 -0
  62. package/dist/react/copy.js.map +1 -0
  63. package/dist/react/fields.d.ts +36 -0
  64. package/dist/react/fields.d.ts.map +1 -0
  65. package/dist/react/fields.js +21 -0
  66. package/dist/react/fields.js.map +1 -0
  67. package/dist/react/hooks.d.ts +9 -0
  68. package/dist/react/hooks.d.ts.map +1 -0
  69. package/dist/react/hooks.js +31 -0
  70. package/dist/react/hooks.js.map +1 -0
  71. package/dist/react/index.d.ts +13 -0
  72. package/dist/react/index.d.ts.map +1 -0
  73. package/dist/react/index.js +13 -0
  74. package/dist/react/index.js.map +1 -0
  75. package/dist/react/parts.d.ts +55 -0
  76. package/dist/react/parts.d.ts.map +1 -0
  77. package/dist/react/parts.js +92 -0
  78. package/dist/react/parts.js.map +1 -0
  79. package/dist/react/ui/alert.d.ts +11 -0
  80. package/dist/react/ui/alert.d.ts.map +1 -0
  81. package/dist/react/ui/alert.js +29 -0
  82. package/dist/react/ui/alert.js.map +1 -0
  83. package/dist/react/ui/avatar.d.ts +12 -0
  84. package/dist/react/ui/avatar.d.ts.map +1 -0
  85. package/dist/react/ui/avatar.js +24 -0
  86. package/dist/react/ui/avatar.js.map +1 -0
  87. package/dist/react/ui/badge.d.ts +10 -0
  88. package/dist/react/ui/badge.d.ts.map +1 -0
  89. package/dist/react/ui/badge.js +26 -0
  90. package/dist/react/ui/badge.js.map +1 -0
  91. package/dist/react/ui/button.d.ts +11 -0
  92. package/dist/react/ui/button.d.ts.map +1 -0
  93. package/dist/react/ui/button.js +37 -0
  94. package/dist/react/ui/button.js.map +1 -0
  95. package/dist/react/ui/card.d.ts +12 -0
  96. package/dist/react/ui/card.d.ts.map +1 -0
  97. package/dist/react/ui/card.js +26 -0
  98. package/dist/react/ui/card.js.map +1 -0
  99. package/dist/react/ui/input.d.ts +4 -0
  100. package/dist/react/ui/input.d.ts.map +1 -0
  101. package/dist/react/ui/input.js +8 -0
  102. package/dist/react/ui/input.js.map +1 -0
  103. package/dist/react/ui/label.d.ts +5 -0
  104. package/dist/react/ui/label.d.ts.map +1 -0
  105. package/dist/react/ui/label.js +10 -0
  106. package/dist/react/ui/label.js.map +1 -0
  107. package/dist/react/ui/radio-group.d.ts +6 -0
  108. package/dist/react/ui/radio-group.d.ts.map +1 -0
  109. package/dist/react/ui/radio-group.js +12 -0
  110. package/dist/react/ui/radio-group.js.map +1 -0
  111. package/dist/react/ui/select.d.ts +16 -0
  112. package/dist/react/ui/select.d.ts.map +1 -0
  113. package/dist/react/ui/select.js +37 -0
  114. package/dist/react/ui/select.js.map +1 -0
  115. package/dist/react/ui/separator.d.ts +5 -0
  116. package/dist/react/ui/separator.d.ts.map +1 -0
  117. package/dist/react/ui/separator.js +10 -0
  118. package/dist/react/ui/separator.js.map +1 -0
  119. package/dist/react/ui/sheet.d.ts +15 -0
  120. package/dist/react/ui/sheet.d.ts.map +1 -0
  121. package/dist/react/ui/sheet.js +38 -0
  122. package/dist/react/ui/sheet.js.map +1 -0
  123. package/dist/react/ui/skeleton.d.ts +3 -0
  124. package/dist/react/ui/skeleton.d.ts.map +1 -0
  125. package/dist/react/ui/skeleton.js +7 -0
  126. package/dist/react/ui/skeleton.js.map +1 -0
  127. package/dist/react/ui/table.d.ts +11 -0
  128. package/dist/react/ui/table.d.ts.map +1 -0
  129. package/dist/react/ui/table.js +29 -0
  130. package/dist/react/ui/table.js.map +1 -0
  131. package/dist/react/ui/tabs.d.ts +12 -0
  132. package/dist/react/ui/tabs.d.ts.map +1 -0
  133. package/dist/react/ui/tabs.js +31 -0
  134. package/dist/react/ui/tabs.js.map +1 -0
  135. package/dist/react/ui/textarea.d.ts +4 -0
  136. package/dist/react/ui/textarea.d.ts.map +1 -0
  137. package/dist/react/ui/textarea.js +8 -0
  138. package/dist/react/ui/textarea.js.map +1 -0
  139. package/dist/react/ui/toggle-group.d.ts +11 -0
  140. package/dist/react/ui/toggle-group.d.ts.map +1 -0
  141. package/dist/react/ui/toggle-group.js +25 -0
  142. package/dist/react/ui/toggle-group.js.map +1 -0
  143. package/dist/react/ui/toggle.d.ts +10 -0
  144. package/dist/react/ui/toggle.d.ts.map +1 -0
  145. package/dist/react/ui/toggle.js +27 -0
  146. package/dist/react/ui/toggle.js.map +1 -0
  147. package/dist/refs.d.ts +16 -0
  148. package/dist/refs.d.ts.map +1 -0
  149. package/dist/refs.js +43 -0
  150. package/dist/refs.js.map +1 -0
  151. package/dist/server/index.d.ts +4 -0
  152. package/dist/server/index.d.ts.map +1 -0
  153. package/dist/server/index.js +4 -0
  154. package/dist/server/index.js.map +1 -0
  155. package/dist/server/recipes.d.ts +39 -0
  156. package/dist/server/recipes.d.ts.map +1 -0
  157. package/dist/server/recipes.js +81 -0
  158. package/dist/server/recipes.js.map +1 -0
  159. package/dist/server/webhooks.d.ts +135 -0
  160. package/dist/server/webhooks.d.ts.map +1 -0
  161. package/dist/server/webhooks.js +243 -0
  162. package/dist/server/webhooks.js.map +1 -0
  163. package/dist/store.d.ts +124 -0
  164. package/dist/store.d.ts.map +1 -0
  165. package/dist/store.js +443 -0
  166. package/dist/store.js.map +1 -0
  167. package/dist/task-client.d.ts +137 -0
  168. package/dist/task-client.d.ts.map +1 -0
  169. package/dist/task-client.js +271 -0
  170. package/dist/task-client.js.map +1 -0
  171. package/dist/tasks.d.ts +84 -0
  172. package/dist/tasks.d.ts.map +1 -0
  173. package/dist/tasks.js +208 -0
  174. package/dist/tasks.js.map +1 -0
  175. package/dist/time.d.ts +15 -0
  176. package/dist/time.d.ts.map +1 -0
  177. package/dist/time.js +34 -0
  178. package/dist/time.js.map +1 -0
  179. package/dist/types.d.ts +74 -0
  180. package/dist/types.d.ts.map +1 -0
  181. package/dist/types.js +9 -0
  182. package/dist/types.js.map +1 -0
  183. package/package.json +101 -4
  184. package/src/activity.ts +86 -0
  185. package/src/blockers.ts +134 -0
  186. package/src/conflicts.ts +115 -0
  187. package/src/draft.ts +162 -0
  188. package/src/errors.ts +40 -0
  189. package/src/index.ts +10 -0
  190. package/src/react/TaskActivity.tsx +187 -0
  191. package/src/react/TaskBoard.tsx +264 -0
  192. package/src/react/TaskConflicts.tsx +217 -0
  193. package/src/react/TaskCreateForm.tsx +165 -0
  194. package/src/react/TaskEditor.tsx +485 -0
  195. package/src/react/TaskFilters.tsx +122 -0
  196. package/src/react/TaskView.tsx +291 -0
  197. package/src/react/TasksAbout.tsx +226 -0
  198. package/src/react/copy.tsx +253 -0
  199. package/src/react/fields.tsx +104 -0
  200. package/src/react/hooks.ts +41 -0
  201. package/src/react/index.ts +12 -0
  202. package/src/react/parts.tsx +176 -0
  203. package/src/react/ui/README.md +6 -0
  204. package/src/react/ui/alert.tsx +75 -0
  205. package/src/react/ui/avatar.tsx +109 -0
  206. package/src/react/ui/badge.tsx +48 -0
  207. package/src/react/ui/button.tsx +66 -0
  208. package/src/react/ui/card.tsx +102 -0
  209. package/src/react/ui/input.tsx +18 -0
  210. package/src/react/ui/label.tsx +23 -0
  211. package/src/react/ui/radio-group.tsx +41 -0
  212. package/src/react/ui/select.tsx +189 -0
  213. package/src/react/ui/separator.tsx +27 -0
  214. package/src/react/ui/sheet.tsx +145 -0
  215. package/src/react/ui/skeleton.tsx +13 -0
  216. package/src/react/ui/table.tsx +113 -0
  217. package/src/react/ui/tabs.tsx +89 -0
  218. package/src/react/ui/textarea.tsx +17 -0
  219. package/src/react/ui/toggle-group.tsx +89 -0
  220. package/src/react/ui/toggle.tsx +44 -0
  221. package/src/refs.ts +49 -0
  222. package/src/server/index.ts +3 -0
  223. package/src/server/recipes.ts +107 -0
  224. package/src/server/webhooks.ts +369 -0
  225. package/src/store.ts +538 -0
  226. package/src/task-client.ts +373 -0
  227. package/src/tasks.ts +263 -0
  228. package/src/time.ts +35 -0
  229. package/src/types.ts +101 -0
@@ -0,0 +1,373 @@
1
+ import {
2
+ Endpoint,
3
+ isBaackApiError,
4
+ type BaackClient,
5
+ type Company,
6
+ type IncludeScope,
7
+ type Search,
8
+ type SearchItem,
9
+ type Task,
10
+ type TaskLog,
11
+ type TaskPriority,
12
+ type TaskStatus,
13
+ type Workflow,
14
+ } from '@baack-software/baack-ts-client';
15
+ import { draft as makeDraft, isEmptyChange, updateBody, validateTask, type DraftInput } from './draft.ts';
16
+ import { changedFields, fieldsChanged, findConflicts, rebaseLists, type TaskField } from './conflicts.ts';
17
+ import { TaskConflictError, TaskSearchTimeoutError, TaskValidationError } from './errors.ts';
18
+ import { ref, urnOf, withRef, withoutRef } from './refs.ts';
19
+ import { TASK_LIST_FIELDS, type RefLike, type TaskDraft, type TaskInput, type TaskListField, type TaskLogPage } from './types.ts';
20
+
21
+ /** Options for a change: who made it, for the activity log. */
22
+ export interface ChangeOptions {
23
+ /** The identity the change is attributed to in the activity log. It does not assign the task. */
24
+ actor?: RefLike;
25
+ }
26
+
27
+ export interface UpdateOptions extends ChangeOptions {
28
+ /**
29
+ * The task as the person saw it when they made the change, usually the copy the editor shows. When given, the update
30
+ * is checked against it: if someone else changed a field this update also changes, it throws `TaskConflictError`
31
+ * and saves nothing; list changes are applied as additions and removals to the lists as they are now.
32
+ */
33
+ base?: Task | undefined;
34
+ /** Told which fields someone else changed since `base` that this update leaves as they are. */
35
+ onChangedElsewhere?: ((fields: (TaskField | TaskListField)[], current: Task) => void) | undefined;
36
+ }
37
+
38
+ export interface CommentOptions {
39
+ /** The identity making the comment. */
40
+ actor?: RefLike;
41
+ /** The log entry this comment replies to. */
42
+ replyTo?: RefLike;
43
+ }
44
+
45
+ export interface PollOptions {
46
+ /** First wait between reads of a running search. Default 250 ms. */
47
+ intervalMs?: number;
48
+ /** Longest wait between reads; waits grow by half each time up to this. Default 2000 ms. */
49
+ maxIntervalMs?: number;
50
+ /** Give up with `TaskSearchTimeoutError` after this long. Default 30000 ms. */
51
+ timeoutMs?: number;
52
+ }
53
+
54
+ export interface ListOptions {
55
+ /** The company whose tasks to list. */
56
+ owner: RefLike;
57
+ /**
58
+ * Also return the identities and projects the reader can see, for assignee and project pickers and names.
59
+ * Default true. Turn off for readers without access to them.
60
+ */
61
+ includeRelated?: boolean;
62
+ /** How many tasks to read at once. Default 6. */
63
+ concurrency?: number;
64
+ poll?: PollOptions;
65
+ /** Stops polling and reading. A request already sent still completes. */
66
+ signal?: AbortSignal;
67
+ }
68
+
69
+ export interface TaskListing {
70
+ /** The tasks, in the search's order. Tasks deleted between the search and the read are left out. */
71
+ tasks: Task[];
72
+ /** Identities seen by the search, with their names. Empty when `includeRelated` is false. */
73
+ identities: SearchItem[];
74
+ /** Projects seen by the search, with their names. Empty when `includeRelated` is false. */
75
+ projects: SearchItem[];
76
+ }
77
+
78
+ /**
79
+ * Typed access to Baack tasks and their activity logs, on top of a `BaackClient` that holds the base URL and
80
+ * credentials.
81
+ *
82
+ * ```ts
83
+ * const tasks = new TaskClient(new BaackClient({ baseUrl: 'https://api.baack.co', credentials: 'include' }));
84
+ * const { tasks: all } = await tasks.list({ owner: companyUrn });
85
+ * await tasks.setStatus(all[0], 'IN_PROGRESS', { actor: me });
86
+ * ```
87
+ */
88
+ export class TaskClient {
89
+ constructor(readonly client: BaackClient) {}
90
+
91
+ // ==========================================
92
+ // TASKS
93
+ // ==========================================
94
+
95
+ async get(task: RefLike): Promise<Task> {
96
+ return this.client.read<Task>(Endpoint.TASK, urnOf(task));
97
+ }
98
+
99
+ /** Reads a workflow: its name and its stages, which tasks move through. */
100
+ async getWorkflow(workflow: RefLike): Promise<Workflow> {
101
+ return this.client.read<Workflow>(Endpoint.WORKFLOW, urnOf(workflow));
102
+ }
103
+
104
+ /**
105
+ * Creates a task. Accepts a full `TaskDraft`, or the owner and title plus any other fields, which `draft` fills in.
106
+ * Throws `TaskValidationError` before sending when the task breaks the API's rules.
107
+ */
108
+ async create(input: TaskDraft | DraftInput, options: ChangeOptions = {}): Promise<Task> {
109
+ const body: TaskInput = withActor(makeDraft(input), options);
110
+ const issues = validateTask(body, { creating: true });
111
+ if (issues.length > 0) throw new TaskValidationError(issues);
112
+ return this.client.create<Task>(Endpoint.TASK, body);
113
+ }
114
+
115
+ /**
116
+ * Changes the task. The API stores the task as an update sends it, clearing optional fields left out, so this reads
117
+ * the task, applies `changes` and sends it whole (see `updateBody`). Lists are sent whole and the server applies the
118
+ * difference. Updates to the same task from this client run one at a time, so each builds on the last.
119
+ *
120
+ * `changes` can be a function of the task as just read, for changes built from its current state, such as adding
121
+ * to a list.
122
+ *
123
+ * Throws `TaskValidationError` before sending when the changes break the API's rules. Returns the task as the
124
+ * server has it after the change.
125
+ *
126
+ * The `BaackClient`'s `failureGuard` applies: retrying a failed update before its backoff has passed fails at once
127
+ * with the same error, sending nothing.
128
+ */
129
+ async update(
130
+ task: RefLike,
131
+ changes: TaskInput | ((current: Task) => TaskInput),
132
+ options: UpdateOptions = {},
133
+ ): Promise<Task> {
134
+ const urn = urnOf(task);
135
+ const { base, onChangedElsewhere, ...change } = options;
136
+ if (typeof changes !== 'function') {
137
+ check(changes, urn);
138
+ if (isEmptyChange(changes) && typeof task !== 'string' && isTask(task)) return task;
139
+ }
140
+ return this.#inTurn(urn, async () => {
141
+ const current = await this.get(urn);
142
+ let changed = typeof changes === 'function' ? changes(current) : changes;
143
+ if (typeof changes === 'function') check(changed, urn);
144
+ if (base) {
145
+ // Reported even when the update then stops on a conflict, since the person sees the task as it is now.
146
+ const touched = new Set<string>([...fieldsChanged(changed), ...TASK_LIST_FIELDS.filter((f) => changed[f] !== undefined)]);
147
+ const elsewhere = changedFields(base, current).filter((field) => !touched.has(field));
148
+ if (elsewhere.length > 0) onChangedElsewhere?.(elsewhere, current);
149
+ const conflicts = findConflicts(base, current, changed);
150
+ if (conflicts.length > 0) throw new TaskConflictError(conflicts, current);
151
+ changed = rebaseLists(base, current, changed);
152
+ }
153
+ return this.client.update<Task>(Endpoint.TASK, urn, withActor(updateBody(current, changed), change));
154
+ });
155
+ }
156
+
157
+ /** Updates waiting or running, by task URN. */
158
+ readonly #turns = new Map<string, Promise<unknown>>();
159
+
160
+ /** Runs `work` after the task's earlier updates settle, whether they succeeded or not. */
161
+ #inTurn<T>(urn: string, work: () => Promise<T>): Promise<T> {
162
+ const result = (this.#turns.get(urn) ?? Promise.resolve()).catch(() => undefined).then(work);
163
+ this.#turns.set(urn, result);
164
+ const forget = () => {
165
+ if (this.#turns.get(urn) === result) this.#turns.delete(urn);
166
+ };
167
+ result.then(forget, forget);
168
+ return result;
169
+ }
170
+
171
+ async delete(task: RefLike): Promise<void> {
172
+ await this.client.delete(Endpoint.TASK, urnOf(task));
173
+ }
174
+
175
+ setStatus(task: RefLike, status: TaskStatus, options?: ChangeOptions): Promise<Task> {
176
+ return this.update(task, { status }, options);
177
+ }
178
+
179
+ setPriority(task: RefLike, priority: TaskPriority, options?: ChangeOptions): Promise<Task> {
180
+ return this.update(task, { priority }, options);
181
+ }
182
+
183
+ /** Assigns the task to `assignee`, or unassigns it with `null`. */
184
+ assign(task: RefLike, assignee: RefLike | null, options?: ChangeOptions): Promise<Task> {
185
+ return this.update(task, { assignee: assignee === null ? null : ref(assignee) }, options);
186
+ }
187
+
188
+ /** Sets the due date (a Baack timestamp, see `toTimestamp`), or removes it with `null`. */
189
+ setDue(task: RefLike, dueTimestamp: string | null, options?: ChangeOptions): Promise<Task> {
190
+ return this.update(task, { dueTimestamp }, options);
191
+ }
192
+
193
+ /** Moves the task into `project`, or out of its project with `null`. */
194
+ setProject(task: RefLike, project: RefLike | null, options?: ChangeOptions): Promise<Task> {
195
+ return this.update(task, { project: project === null ? null : ref(project) }, options);
196
+ }
197
+
198
+ /** Moves the task to a workflow stage, or out of its stage with `null`. */
199
+ setWorkflowStage(task: RefLike, stage: RefLike | null, options?: ChangeOptions): Promise<Task> {
200
+ return this.update(task, { workflowStage: stage === null ? null : ref(stage) }, options);
201
+ }
202
+
203
+ /** Marks `blocker` as blocking `task`. */
204
+ addBlocker(task: RefLike, blocker: RefLike, options?: ChangeOptions): Promise<Task> {
205
+ return this.addTo(task, 'blockedBy', blocker, options);
206
+ }
207
+
208
+ removeBlocker(task: RefLike, blocker: RefLike, options?: ChangeOptions): Promise<Task> {
209
+ return this.removeFrom(task, 'blockedBy', blocker, options);
210
+ }
211
+
212
+ /** Adds a reference to one of the task's lists, as the server has the list when the update is sent. */
213
+ addTo(task: RefLike, field: TaskListField, value: RefLike, options?: ChangeOptions): Promise<Task> {
214
+ return this.update(task, (current) => ({ [field]: withRef(current[field], value) }), options);
215
+ }
216
+
217
+ /** Removes a reference from one of the task's lists, as the server has the list when the update is sent. */
218
+ removeFrom(task: RefLike, field: TaskListField, value: RefLike, options?: ChangeOptions): Promise<Task> {
219
+ return this.update(task, (current) => ({ [field]: withoutRef(current[field], value) }), options);
220
+ }
221
+
222
+ // ==========================================
223
+ // ACTIVITY LOG
224
+ // ==========================================
225
+
226
+ /** One page of the task's activity log, newest first. Pass `pagination.after` from a page to read the next. */
227
+ async logs(task: RefLike, options: { after?: string } = {}): Promise<TaskLogPage> {
228
+ const page = await this.client.read<TaskLogPage | undefined>(Endpoint.TASK_LOGS, urnOf(task), {
229
+ after: options.after,
230
+ });
231
+ return { logs: page?.logs ?? [], pagination: page?.pagination ?? null };
232
+ }
233
+
234
+ /** The task's activity log across pages, newest first, stopping after `limit` entries when given. */
235
+ async allLogs(task: RefLike, options: { limit?: number } = {}): Promise<TaskLog[]> {
236
+ const { limit = Number.POSITIVE_INFINITY } = options;
237
+ const logs: TaskLog[] = [];
238
+ let after: string | undefined;
239
+ do {
240
+ const page = await this.logs(task, after === undefined ? {} : { after });
241
+ logs.push(...page.logs);
242
+ after = page.pagination?.isMorePages ? (page.pagination.after ?? undefined) : undefined;
243
+ } while (after !== undefined && logs.length < limit);
244
+ return logs.slice(0, limit);
245
+ }
246
+
247
+ /** Adds a comment to the task's activity log. */
248
+ async comment(task: RefLike, comment: string, options: CommentOptions = {}): Promise<TaskLog> {
249
+ const body: Record<string, unknown> = { task: ref(task), activity: 'COMMENT_ADDED', comment };
250
+ if (options.actor !== undefined) body['identity'] = ref(options.actor);
251
+ if (options.replyTo !== undefined) body['replyTo'] = ref(options.replyTo);
252
+ return this.client.create<TaskLog>(Endpoint.TASK_LOG, body);
253
+ }
254
+
255
+ // ==========================================
256
+ // LISTING
257
+ // ==========================================
258
+
259
+ /**
260
+ * Lists a company's tasks. Runs a search, waits for its full result, follows its pages, then
261
+ * reads each task (search results don't carry the task itself yet).
262
+ */
263
+ async list(options: ListOptions): Promise<TaskListing> {
264
+ const { includeRelated = true, concurrency = 6, signal } = options;
265
+ const scopes: IncludeScope[] = includeRelated ? ['TASK', 'IDENTITY', 'PROJECT'] : ['TASK'];
266
+ const items = await this.search(
267
+ { owner: options.owner, scopes },
268
+ { ...(options.poll ?? {}), ...(signal ? { signal } : {}) },
269
+ );
270
+
271
+ const taskItems = items.filter((item) => item.type === 'TASK');
272
+ const tasks = await mapLimit(taskItems, concurrency, signal, async (item) => {
273
+ if (item.item && isTask(item.item)) return item.item;
274
+ try {
275
+ return await this.get(item.urn);
276
+ } catch (error) {
277
+ if (isBaackApiError(error) && error.isNotFound) return undefined;
278
+ throw error;
279
+ }
280
+ });
281
+
282
+ return {
283
+ tasks: tasks.filter((task): task is Task => task !== undefined),
284
+ identities: items.filter((item) => item.type === 'IDENTITY'),
285
+ projects: items.filter((item) => item.type === 'PROJECT'),
286
+ };
287
+ }
288
+
289
+ /**
290
+ * Runs a search and returns every item once the result is full, across pages. Exposed for callers that want other
291
+ * scopes alongside tasks.
292
+ */
293
+ async search(
294
+ query: { owner: RefLike; scopes: IncludeScope[] },
295
+ options: PollOptions & { signal?: AbortSignal } = {},
296
+ ): Promise<SearchItem[]> {
297
+ const { intervalMs = 250, maxIntervalMs = 2000, timeoutMs = 30000, signal } = options;
298
+ const deadline = Date.now() + timeoutMs;
299
+
300
+ // The API resolves the owner by URN; the client's Company type also wants a url.
301
+ const body: Search = { owner: ref(query.owner) as Company, includeScopes: query.scopes };
302
+
303
+ signal?.throwIfAborted();
304
+ let search = await this.client.search(body);
305
+ let wait = intervalMs;
306
+ while (search.status !== 'FULL_RESULT') {
307
+ if (!search.urn) throw new Error('The search API returned a search without a URN');
308
+ if (Date.now() + wait > deadline) throw new TaskSearchTimeoutError(search.urn, timeoutMs);
309
+ await sleep(wait, signal);
310
+ search = await this.client.searchResult(search.urn);
311
+ wait = Math.min(Math.round(wait * 1.5), maxIntervalMs);
312
+ }
313
+
314
+ const items = [...(search.items ?? [])];
315
+ let pagination = search.pagination;
316
+ while (pagination?.isMorePages && pagination.after && search.urn) {
317
+ signal?.throwIfAborted();
318
+ const page = await this.client.searchResult(search.urn, pagination.after);
319
+ items.push(...(page.items ?? []));
320
+ pagination = page.pagination;
321
+ }
322
+ return items;
323
+ }
324
+ }
325
+
326
+ /** Throws `TaskValidationError` when the changes break the API's rules. */
327
+ function check(changes: TaskInput, urn: string): void {
328
+ const issues = validateTask(changes, { urn });
329
+ if (issues.length > 0) throw new TaskValidationError(issues);
330
+ }
331
+
332
+ function withActor<T extends TaskInput>(input: T, options: ChangeOptions): T {
333
+ return options.actor === undefined ? input : { ...input, identityForLogs: ref(options.actor) };
334
+ }
335
+
336
+ function isTask(value: unknown): value is Task {
337
+ return typeof value === 'object' && value !== null && 'urn' in value && 'title' in value && 'status' in value;
338
+ }
339
+
340
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
341
+ return new Promise((resolve, reject) => {
342
+ if (signal?.aborted) return reject(signal.reason);
343
+ const timer = setTimeout(() => {
344
+ signal?.removeEventListener('abort', onAbort);
345
+ resolve();
346
+ }, ms);
347
+ const onAbort = () => {
348
+ clearTimeout(timer);
349
+ reject(signal?.reason);
350
+ };
351
+ signal?.addEventListener('abort', onAbort, { once: true });
352
+ });
353
+ }
354
+
355
+ /** Maps `items` with at most `limit` calls running at once, keeping the input order. */
356
+ async function mapLimit<T, R>(
357
+ items: readonly T[],
358
+ limit: number,
359
+ signal: AbortSignal | undefined,
360
+ fn: (item: T) => Promise<R>,
361
+ ): Promise<R[]> {
362
+ const results = new Array<R>(items.length);
363
+ let next = 0;
364
+ const worker = async () => {
365
+ while (next < items.length) {
366
+ signal?.throwIfAborted();
367
+ const index = next++;
368
+ results[index] = await fn(items[index]!);
369
+ }
370
+ };
371
+ await Promise.all(Array.from({ length: Math.min(Math.max(1, limit), items.length) }, worker));
372
+ return results;
373
+ }
package/src/tasks.ts ADDED
@@ -0,0 +1,263 @@
1
+ import type { Task, TaskPriority, TaskStatus, Workflow, WorkflowStage } from '@baack-software/baack-ts-client';
2
+ import { urnOf } from './refs.ts';
3
+ import { parseTimestamp } from './time.ts';
4
+ import type { RefLike } from './types.ts';
5
+
6
+ /** Statuses in board order, from not started to done. */
7
+ export const TASK_STATUSES: readonly TaskStatus[] = [
8
+ 'UNDEFINED',
9
+ 'PENDING',
10
+ 'IN_PROGRESS',
11
+ 'BLOCKED',
12
+ 'COMPLETED',
13
+ 'CLOSED',
14
+ ];
15
+
16
+ /** Priorities from lowest to highest. */
17
+ export const TASK_PRIORITIES: readonly TaskPriority[] = ['NONE', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
18
+
19
+ export const STATUS_LABELS: Readonly<Record<TaskStatus, string>> = {
20
+ UNDEFINED: 'Undefined',
21
+ PENDING: 'Pending',
22
+ IN_PROGRESS: 'In progress',
23
+ BLOCKED: 'Blocked',
24
+ COMPLETED: 'Completed',
25
+ CLOSED: 'Closed',
26
+ };
27
+
28
+ export const PRIORITY_LABELS: Readonly<Record<TaskPriority, string>> = {
29
+ NONE: 'None',
30
+ LOW: 'Low',
31
+ MEDIUM: 'Medium',
32
+ HIGH: 'High',
33
+ CRITICAL: 'Critical',
34
+ };
35
+
36
+ const DONE: ReadonlySet<TaskStatus> = new Set(['COMPLETED', 'CLOSED']);
37
+
38
+ /** Whether a status means the work is finished: completed or closed. */
39
+ export function isDoneStatus(status: TaskStatus | null | undefined): boolean {
40
+ return status != null && DONE.has(status);
41
+ }
42
+
43
+ /** Whether the task still needs work: not completed or closed. */
44
+ export function isOpen(task: Pick<Task, 'status'>): boolean {
45
+ return !isDoneStatus(task.status);
46
+ }
47
+
48
+ /** The task's due date, or undefined when it has none. */
49
+ export function dueDate(task: Pick<Task, 'dueTimestamp'>): Date | undefined {
50
+ return parseTimestamp(task.dueTimestamp);
51
+ }
52
+
53
+ /** Whether an open task is past its due date. */
54
+ export function isOverdue(task: Pick<Task, 'status' | 'dueTimestamp'>, now: Date = new Date()): boolean {
55
+ const due = dueDate(task);
56
+ return isOpen(task) && due !== undefined && due.getTime() < now.getTime();
57
+ }
58
+
59
+ /** Whether an open task is due within `ms` from now, and not yet overdue. */
60
+ export function isDueWithin(
61
+ task: Pick<Task, 'status' | 'dueTimestamp'>,
62
+ ms: number,
63
+ now: Date = new Date(),
64
+ ): boolean {
65
+ const due = dueDate(task);
66
+ if (!isOpen(task) || due === undefined) return false;
67
+ const left = due.getTime() - now.getTime();
68
+ return left >= 0 && left <= ms;
69
+ }
70
+
71
+ // ==========================================
72
+ // SORTING
73
+ // ==========================================
74
+
75
+ export type TaskComparator = (a: Task, b: Task) => number;
76
+
77
+ /** Highest priority first. */
78
+ export const byPriority: TaskComparator = (a, b) =>
79
+ TASK_PRIORITIES.indexOf(b.priority) - TASK_PRIORITIES.indexOf(a.priority);
80
+
81
+ /** Board order of status. */
82
+ export const byStatus: TaskComparator = (a, b) => TASK_STATUSES.indexOf(a.status) - TASK_STATUSES.indexOf(b.status);
83
+
84
+ /** Soonest due first; tasks without a due date last. */
85
+ export const byDue: TaskComparator = (a, b) => {
86
+ const [first, second] = [timeOrInfinity(a.dueTimestamp), timeOrInfinity(b.dueTimestamp)];
87
+ // Two tasks without due dates tie (Infinity - Infinity is NaN, which would stop the next comparator breaking it).
88
+ return first === second ? 0 : first - second;
89
+ };
90
+
91
+ /** Most recently updated first. */
92
+ export const byUpdated: TaskComparator = (a, b) =>
93
+ -(timeOrInfinity(a.updatedTimestamp, 0) - timeOrInfinity(b.updatedTimestamp, 0));
94
+
95
+ /** Title, alphabetically. */
96
+ export const byTitle: TaskComparator = (a, b) => a.title.localeCompare(b.title);
97
+
98
+ /** Sorts by each comparator in turn, using the next to break ties. */
99
+ export function compareBy(...comparators: TaskComparator[]): TaskComparator {
100
+ return (a, b) => {
101
+ for (const compare of comparators) {
102
+ const result = compare(a, b);
103
+ if (result !== 0) return result;
104
+ }
105
+ return 0;
106
+ };
107
+ }
108
+
109
+ /** Open tasks before completed and closed ones. */
110
+ export const byOpen: TaskComparator = (a, b) => Number(isOpen(b)) - Number(isOpen(a));
111
+
112
+ /** A sensible default for work lists: open first, then overdue and soonest due, then highest priority. */
113
+ export const byUrgency: TaskComparator = compareBy(byOpen, byDue, byPriority, byTitle);
114
+
115
+ /** Within a board column: open first, then highest priority, soonest due and title, so ties are stable. */
116
+ export const byPriorityThenDue: TaskComparator = compareBy(byOpen, byPriority, byDue, byTitle);
117
+
118
+ function timeOrInfinity(timestamp: string | null | undefined, fallback = Number.POSITIVE_INFINITY): number {
119
+ return parseTimestamp(timestamp)?.getTime() ?? fallback;
120
+ }
121
+
122
+ // ==========================================
123
+ // FILTERING
124
+ // ==========================================
125
+
126
+ /**
127
+ * Criteria for `matchesFilter`. Each criterion left out matches every task; all given criteria must match. For
128
+ * `assignee`, `project` and `workflowStage`, `null` in the list matches tasks with none set.
129
+ */
130
+ export interface TaskFilter {
131
+ status?: readonly TaskStatus[];
132
+ priority?: readonly TaskPriority[];
133
+ assignee?: readonly (RefLike | null)[];
134
+ project?: readonly (RefLike | null)[];
135
+ workflowStage?: readonly (RefLike | null)[];
136
+ /** Tasks that block on, are blocking, or are associated with this representation. */
137
+ relatedTo?: RefLike;
138
+ /** `true` for open tasks only, `false` for done tasks only. */
139
+ open?: boolean;
140
+ /** Only open tasks past their due date. */
141
+ overdue?: boolean;
142
+ /** Case-insensitive match against the title and description. */
143
+ text?: string;
144
+ }
145
+
146
+ export function matchesFilter(task: Task, filter: TaskFilter, now: Date = new Date()): boolean {
147
+ if (filter.status && !filter.status.includes(task.status)) return false;
148
+ if (filter.priority && !filter.priority.includes(task.priority)) return false;
149
+ if (filter.assignee && !matchesRef(task.assignee, filter.assignee)) return false;
150
+ if (filter.project && !matchesRef(task.project, filter.project)) return false;
151
+ if (filter.workflowStage && !matchesRef(task.workflowStage, filter.workflowStage)) return false;
152
+ if (filter.relatedTo !== undefined && !isRelatedTo(task, filter.relatedTo)) return false;
153
+ if (filter.open !== undefined && isOpen(task) !== filter.open) return false;
154
+ if (filter.overdue && !isOverdue(task, now)) return false;
155
+ if (filter.text) {
156
+ const needle = filter.text.toLocaleLowerCase();
157
+ const haystack = `${task.title}\n${task.description}`.toLocaleLowerCase();
158
+ if (!haystack.includes(needle)) return false;
159
+ }
160
+ return true;
161
+ }
162
+
163
+ export function filterTasks(tasks: readonly Task[], filter: TaskFilter, now: Date = new Date()): Task[] {
164
+ return tasks.filter((task) => matchesFilter(task, filter, now));
165
+ }
166
+
167
+ /** Whether the task refers to `target` in its blockers, blocked tasks or associations. */
168
+ export function isRelatedTo(task: Task, target: RefLike): boolean {
169
+ const urn = urnOf(target);
170
+ const lists = [
171
+ task.blockedBy,
172
+ task.blocking,
173
+ task.associatedTasks,
174
+ task.associatedEntities,
175
+ task.associatedIdentities,
176
+ task.associatedCompanies,
177
+ ];
178
+ return lists.some((list) => list?.some((item) => item.urn === urn));
179
+ }
180
+
181
+ function matchesRef(value: { urn: string } | null | undefined, wanted: readonly (RefLike | null)[]): boolean {
182
+ return wanted.some((candidate) => (candidate === null ? value == null : value?.urn === urnOf(candidate)));
183
+ }
184
+
185
+ // ==========================================
186
+ // GROUPING
187
+ // ==========================================
188
+
189
+ export type TaskGrouping = 'status' | 'priority' | 'assignee' | 'project' | 'workflow' | 'workflowStage';
190
+
191
+ /** The key used for tasks without an assignee, project, workflow or workflow stage. */
192
+ export const NONE_KEY = '';
193
+
194
+ /**
195
+ * Groups tasks into columns, keyed by status, priority, or the URN of the assignee, project or workflow stage
196
+ * (`NONE_KEY` when unset). Status and priority groups always contain every value in order, so a board shows empty
197
+ * columns; priority runs highest first. Other groups appear in order of first use.
198
+ */
199
+ export function groupTasks(tasks: readonly Task[], by: TaskGrouping): Map<string, Task[]> {
200
+ const groups = new Map<string, Task[]>();
201
+ if (by === 'status') TASK_STATUSES.forEach((status) => groups.set(status, []));
202
+ if (by === 'priority') [...TASK_PRIORITIES].reverse().forEach((priority) => groups.set(priority, []));
203
+ for (const task of tasks) {
204
+ const key = groupKey(task, by);
205
+ const group = groups.get(key);
206
+ if (group) group.push(task);
207
+ else groups.set(key, [task]);
208
+ }
209
+ return groups;
210
+ }
211
+
212
+ /** A workflow's stages in order. */
213
+ export function workflowStages(workflow: Workflow): WorkflowStage[] {
214
+ return [...(workflow.stages ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
215
+ }
216
+
217
+ /**
218
+ * Groups tasks into a workflow's stages, keyed by stage URN, every stage in order even when empty. Tasks in no stage,
219
+ * or in another workflow's, go under `NONE_KEY`, last, which is left out when empty.
220
+ */
221
+ export function groupByStage(tasks: readonly Task[], workflow: Workflow): Map<string, Task[]> {
222
+ const groups = new Map<string, Task[]>(workflowStages(workflow).map((stage) => [stage.urn, []]));
223
+ const rest: Task[] = [];
224
+ for (const task of tasks) (groups.get(task.workflowStage?.urn ?? NONE_KEY) ?? rest).push(task);
225
+ if (rest.length > 0) groups.set(NONE_KEY, rest);
226
+ return groups;
227
+ }
228
+
229
+ /** The workflow a task is in: the one its stage belongs to, as a task records only its stage. */
230
+ export function workflowOf(task: Task, workflows: readonly Workflow[]): Workflow | undefined {
231
+ const stage = task.workflowStage?.urn;
232
+ return stage ? workflows.find((workflow) => workflow.stages?.some((s) => s.urn === stage)) : undefined;
233
+ }
234
+
235
+ /**
236
+ * Groups tasks by workflow, keyed by workflow URN, every workflow in the order given even when empty. Tasks in no
237
+ * workflow's stage go under `NONE_KEY`, last, which is left out when empty.
238
+ */
239
+ export function groupByWorkflow(tasks: readonly Task[], workflows: readonly Workflow[]): Map<string, Task[]> {
240
+ const groups = new Map<string, Task[]>(workflows.map((workflow) => [workflow.urn, []]));
241
+ const rest: Task[] = [];
242
+ for (const task of tasks) (groups.get(workflowOf(task, workflows)?.urn ?? NONE_KEY) ?? rest).push(task);
243
+ if (rest.length > 0) groups.set(NONE_KEY, rest);
244
+ return groups;
245
+ }
246
+
247
+ function groupKey(task: Task, by: TaskGrouping): string {
248
+ switch (by) {
249
+ case 'status':
250
+ return task.status;
251
+ case 'priority':
252
+ return task.priority;
253
+ case 'assignee':
254
+ return task.assignee?.urn ?? NONE_KEY;
255
+ case 'project':
256
+ return task.project?.urn ?? NONE_KEY;
257
+ case 'workflowStage':
258
+ return task.workflowStage?.urn ?? NONE_KEY;
259
+ // A task's workflow is found from its stage, which needs the workflows: `groupByWorkflow`.
260
+ case 'workflow':
261
+ return NONE_KEY;
262
+ }
263
+ }
package/src/time.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Baack timestamps are ISO 8601 in UTC without a zone, for example `2025-09-13T17:08:00` or `2025-09-13T17:08:00.000`.
3
+ * A plain `new Date(timestamp)` would read them as local time, so use these helpers instead.
4
+ */
5
+
6
+ const HAS_ZONE = /(?:Z|[+-]\d{2}:?\d{2})$/i;
7
+
8
+ /** Reads a Baack timestamp as a `Date`, or undefined when it is empty or not a valid timestamp. */
9
+ export function parseTimestamp(timestamp: string | null | undefined): Date | undefined {
10
+ if (!timestamp) return undefined;
11
+ const date = new Date(HAS_ZONE.test(timestamp) ? timestamp : timestamp + 'Z');
12
+ return Number.isNaN(date.getTime()) ? undefined : date;
13
+ }
14
+
15
+ /** Writes a `Date` as a Baack timestamp: UTC, with milliseconds, without a zone. */
16
+ export function toTimestamp(date: Date): string {
17
+ return date.toISOString().replace(/Z$/, '');
18
+ }
19
+
20
+ /**
21
+ * The value for an `<input type="datetime-local">` showing a Baack timestamp in the viewer's local time, or `''`.
22
+ */
23
+ export function toLocalInput(timestamp: string | null | undefined): string {
24
+ const date = parseTimestamp(timestamp);
25
+ if (!date) return '';
26
+ const pad = (n: number) => String(n).padStart(2, '0');
27
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
28
+ }
29
+
30
+ /** Reads an `<input type="datetime-local">` value (local time) as a Baack timestamp, or undefined when empty. */
31
+ export function fromLocalInput(value: string | null | undefined): string | undefined {
32
+ if (!value) return undefined;
33
+ const date = new Date(value);
34
+ return Number.isNaN(date.getTime()) ? undefined : toTimestamp(date);
35
+ }