@grec0/memory-bank-mcp 0.0.2

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.
@@ -0,0 +1,268 @@
1
+ /**
2
+ * @fileoverview Board operations for the MCP Kanban server
3
+ *
4
+ * This module provides functions for interacting with boards in the Planka Kanban system,
5
+ * including creating, retrieving, updating, and deleting boards. It also handles
6
+ * the creation of default lists and labels when a new board is created.
7
+ */
8
+ import { z } from "zod";
9
+ import { plankaRequest } from "../common/utils.js";
10
+ import { PlankaBoardSchema } from "../common/types.js";
11
+ import { getAdminUserId } from "../common/setup.js";
12
+ import * as boardMemberships from "./boardMemberships.js";
13
+ import * as lists from "./lists.js";
14
+ import * as labels from "./labels.js";
15
+ // Schema definitions
16
+ /**
17
+ * Schema for creating a new board
18
+ * @property {string} projectId - The ID of the project to create the board in
19
+ * @property {string} name - The name of the board
20
+ * @property {number} [position] - The position of the board in the project (default: 65535)
21
+ */
22
+ export const CreateBoardSchema = z.object({
23
+ projectId: z.string().describe("Project ID"),
24
+ name: z.string().describe("Board name"),
25
+ position: z.number().default(65535).describe("Board position (default: 65535)"),
26
+ });
27
+ /**
28
+ * Schema for retrieving boards from a project
29
+ * @property {string} projectId - The ID of the project to get boards from
30
+ */
31
+ export const GetBoardsSchema = z.object({
32
+ projectId: z.string().describe("Project ID"),
33
+ });
34
+ /**
35
+ * Schema for retrieving a specific board
36
+ * @property {string} id - The ID of the board to retrieve
37
+ */
38
+ export const GetBoardSchema = z.object({
39
+ id: z.string().describe("Board ID"),
40
+ });
41
+ /**
42
+ * Schema for updating a board
43
+ * @property {string} id - The ID of the board to update
44
+ * @property {string} [name] - The new name for the board
45
+ * @property {number} [position] - The new position for the board
46
+ * @property {string} [type] - The type of the board
47
+ */
48
+ export const UpdateBoardSchema = z.object({
49
+ id: z.string().describe("Board ID"),
50
+ name: z.string().optional().describe("Board name"),
51
+ position: z.number().optional().describe("Board position"),
52
+ type: z.string().optional().describe("Board type"),
53
+ });
54
+ /**
55
+ * Schema for deleting a board
56
+ * @property {string} id - The ID of the board to delete
57
+ */
58
+ export const DeleteBoardSchema = z.object({
59
+ id: z.string().describe("Board ID"),
60
+ });
61
+ // Response schemas
62
+ const BoardsResponseSchema = z.object({
63
+ items: z.array(PlankaBoardSchema),
64
+ included: z.record(z.any()).optional(),
65
+ });
66
+ const BoardResponseSchema = z.object({
67
+ item: PlankaBoardSchema,
68
+ included: z.record(z.any()).optional(),
69
+ });
70
+ // Function implementations
71
+ /**
72
+ * Creates default lists for a new board
73
+ *
74
+ * @param {string} boardId - The ID of the board to create lists for
75
+ * @returns {Promise<void>}
76
+ * @private
77
+ */
78
+ async function createDefaultLists(boardId) {
79
+ try {
80
+ const defaultLists = [
81
+ { name: "Backlog", position: 65535 },
82
+ { name: "To Do", position: 131070 },
83
+ { name: "In Progress", position: 196605 },
84
+ { name: "On Hold", position: 262140 },
85
+ { name: "Review", position: 327675 },
86
+ { name: "Done", position: 393210 },
87
+ ];
88
+ for (const list of defaultLists) {
89
+ await lists.createList({
90
+ boardId,
91
+ name: list.name,
92
+ position: list.position,
93
+ });
94
+ }
95
+ }
96
+ catch (error) {
97
+ console.error(`Error creating default lists for board ${boardId}:`, error instanceof Error ? error.message : String(error));
98
+ }
99
+ }
100
+ /**
101
+ * Creates default labels for a new board
102
+ * @param boardId The ID of the board to create labels for
103
+ */
104
+ async function createDefaultLabels(boardId) {
105
+ try {
106
+ // Priority labels
107
+ const priorityLabels = [
108
+ { name: "P0: Critical", color: "berry-red", position: 65535 },
109
+ { name: "P1: High", color: "red-burgundy", position: 131070 },
110
+ { name: "P2: Medium", color: "pumpkin-orange", position: 196605 },
111
+ { name: "P3: Low", color: "sunny-grass", position: 262140 },
112
+ ];
113
+ // Type labels
114
+ const typeLabels = [
115
+ { name: "Bug", color: "coral-green", position: 327675 },
116
+ { name: "Feature", color: "lagoon-blue", position: 393210 },
117
+ { name: "Enhancement", color: "bright-moss", position: 458745 },
118
+ { name: "Documentation", color: "light-orange", position: 524280 },
119
+ ];
120
+ // Status labels
121
+ const statusLabels = [
122
+ { name: "Blocked", color: "midnight-blue", position: 589815 },
123
+ { name: "Needs Info", color: "desert-sand", position: 655350 },
124
+ { name: "Ready", color: "egg-yellow", position: 720885 },
125
+ ];
126
+ // Combine all labels
127
+ const defaultLabels = [
128
+ ...priorityLabels,
129
+ ...typeLabels,
130
+ ...statusLabels,
131
+ ];
132
+ for (const label of defaultLabels) {
133
+ await labels.createLabel({
134
+ boardId,
135
+ name: label.name,
136
+ color: label.color, // Type assertion needed due to enum constraints
137
+ position: label.position,
138
+ });
139
+ }
140
+ }
141
+ catch (error) {
142
+ console.error(`Error creating default labels for board ${boardId}:`, error instanceof Error ? error.message : String(error));
143
+ }
144
+ }
145
+ /**
146
+ * Creates a new board in a project with default lists and labels
147
+ *
148
+ * @param {CreateBoardOptions} options - Options for creating the board
149
+ * @param {string} options.projectId - The ID of the project to create the board in
150
+ * @param {string} options.name - The name of the board
151
+ * @param {number} [options.position] - The position of the board in the project
152
+ * @returns {Promise<object>} The created board
153
+ * @throws {Error} If the board creation fails
154
+ */
155
+ export async function createBoard(options) {
156
+ try {
157
+ const response = await plankaRequest(`/api/projects/${options.projectId}/boards`, {
158
+ method: "POST",
159
+ body: {
160
+ name: options.name,
161
+ position: options.position,
162
+ },
163
+ });
164
+ const parsedResponse = BoardResponseSchema.parse(response);
165
+ const board = parsedResponse.item;
166
+ // Add the admin user as a board member
167
+ try {
168
+ const adminUserId = await getAdminUserId();
169
+ if (adminUserId) {
170
+ await boardMemberships.createBoardMembership({
171
+ boardId: board.id,
172
+ userId: adminUserId,
173
+ role: "editor",
174
+ });
175
+ }
176
+ else {
177
+ console.error("Could not add admin user as board member: Admin user ID not found");
178
+ }
179
+ }
180
+ catch (error) {
181
+ console.error("Error adding admin user as board member:", error);
182
+ }
183
+ // Create default lists and labels
184
+ await createDefaultLists(board.id);
185
+ await createDefaultLabels(board.id);
186
+ return board;
187
+ }
188
+ catch (error) {
189
+ throw new Error(`Failed to create board: ${error instanceof Error ? error.message : String(error)}`);
190
+ }
191
+ }
192
+ /**
193
+ * Retrieves all boards for a specific project
194
+ *
195
+ * @param {string} projectId - The ID of the project to get boards from
196
+ * @returns {Promise<Array<object>>} Array of boards in the project
197
+ * @throws {Error} If retrieving boards fails
198
+ */
199
+ export async function getBoards(projectId) {
200
+ try {
201
+ // Get all projects which includes boards in the response
202
+ const response = await plankaRequest(`/api/projects`);
203
+ // Check if the response has the expected structure
204
+ if (response &&
205
+ typeof response === "object" &&
206
+ "included" in response &&
207
+ response.included &&
208
+ typeof response.included === "object" &&
209
+ "boards" in response.included) {
210
+ // Filter boards by projectId
211
+ const allBoards = response.included.boards;
212
+ if (Array.isArray(allBoards)) {
213
+ const filteredBoards = allBoards.filter((board) => typeof board === "object" &&
214
+ board !== null &&
215
+ "projectId" in board &&
216
+ board.projectId === projectId);
217
+ return filteredBoards;
218
+ }
219
+ }
220
+ // If we can't find boards in the expected format, return an empty array
221
+ return [];
222
+ }
223
+ catch (error) {
224
+ // If all else fails, return an empty array
225
+ return [];
226
+ }
227
+ }
228
+ /**
229
+ * Retrieves a specific board by ID
230
+ *
231
+ * @param {string} id - The ID of the board to retrieve
232
+ * @returns {Promise<object>} The requested board
233
+ * @throws {Error} If retrieving the board fails
234
+ */
235
+ export async function getBoard(id) {
236
+ const response = await plankaRequest(`/api/boards/${id}`);
237
+ const parsedResponse = BoardResponseSchema.parse(response);
238
+ return parsedResponse.item;
239
+ }
240
+ /**
241
+ * Updates a board's properties
242
+ *
243
+ * @param {string} id - The ID of the board to update
244
+ * @param {Partial<Omit<CreateBoardOptions, "projectId">>} options - The properties to update
245
+ * @returns {Promise<object>} The updated board
246
+ * @throws {Error} If updating the board fails
247
+ */
248
+ export async function updateBoard(id, options) {
249
+ const response = await plankaRequest(`/api/boards/${id}`, {
250
+ method: "PATCH",
251
+ body: options,
252
+ });
253
+ const parsedResponse = BoardResponseSchema.parse(response);
254
+ return parsedResponse.item;
255
+ }
256
+ /**
257
+ * Deletes a board by ID
258
+ *
259
+ * @param {string} id - The ID of the board to delete
260
+ * @returns {Promise<{success: boolean}>} Success indicator
261
+ * @throws {Error} If deleting the board fails
262
+ */
263
+ export async function deleteBoard(id) {
264
+ await plankaRequest(`/api/boards/${id}`, {
265
+ method: "DELETE",
266
+ });
267
+ return { success: true };
268
+ }
@@ -0,0 +1,426 @@
1
+ /**
2
+ * @fileoverview Card operations for the MCP Kanban server
3
+ *
4
+ * This module provides functions for interacting with cards in the Planka Kanban board,
5
+ * including creating, retrieving, updating, moving, duplicating, and deleting cards,
6
+ * as well as managing card stopwatches for time tracking.
7
+ */
8
+ import { z } from "zod";
9
+ import { plankaRequest } from "../common/utils.js";
10
+ import { PlankaCardSchema } from "../common/types.js";
11
+ // Schema definitions
12
+ /**
13
+ * Schema for creating a new card
14
+ * @property {string} listId - The ID of the list to create the card in
15
+ * @property {string} name - The name of the card
16
+ * @property {string} [description] - The description of the card
17
+ * @property {number} [position] - The position of the card in the list (default: 65535)
18
+ */
19
+ export const CreateCardSchema = z.object({
20
+ listId: z.string().describe("List ID"),
21
+ name: z.string().describe("Card name"),
22
+ description: z.string().optional().describe("Card description"),
23
+ position: z.number().optional().describe("Card position (default: 65535)"),
24
+ });
25
+ /**
26
+ * Schema for retrieving cards from a list
27
+ * @property {string} listId - The ID of the list to get cards from
28
+ */
29
+ export const GetCardsSchema = z.object({
30
+ listId: z.string().describe("List ID"),
31
+ });
32
+ /**
33
+ * Schema for retrieving a specific card
34
+ * @property {string} id - The ID of the card to retrieve
35
+ */
36
+ export const GetCardSchema = z.object({
37
+ id: z.string().describe("Card ID"),
38
+ });
39
+ /**
40
+ * Schema for updating a card
41
+ * @property {string} id - The ID of the card to update
42
+ * @property {string} [name] - The new name for the card
43
+ * @property {string} [description] - The new description for the card
44
+ * @property {number} [position] - The new position for the card
45
+ * @property {string} [dueDate] - The due date for the card (ISO format)
46
+ * @property {boolean} [isCompleted] - Whether the card is completed
47
+ */
48
+ export const UpdateCardSchema = z.object({
49
+ id: z.string().describe("Card ID"),
50
+ name: z.string().optional().describe("Card name"),
51
+ description: z.string().optional().describe("Card description"),
52
+ position: z.number().optional().describe("Card position"),
53
+ dueDate: z.string().optional().describe("Card due date (ISO format)"),
54
+ isCompleted: z.boolean().optional().describe("Whether the card is completed"),
55
+ });
56
+ export const MoveCardSchema = z.object({
57
+ id: z.string().describe("Card ID"),
58
+ listId: z.string().describe("Target list ID"),
59
+ position: z.number().optional().describe("Card position in the target list (default: 65535)"),
60
+ });
61
+ export const DuplicateCardSchema = z.object({
62
+ id: z.string().describe("Card ID to duplicate"),
63
+ position: z.number().optional().describe("Position for the duplicated card (default: 65535)"),
64
+ });
65
+ export const DeleteCardSchema = z.object({
66
+ id: z.string().describe("Card ID"),
67
+ });
68
+ // Stopwatch schemas
69
+ export const StartCardStopwatchSchema = z.object({
70
+ id: z.string().describe("Card ID"),
71
+ });
72
+ export const StopCardStopwatchSchema = z.object({
73
+ id: z.string().describe("Card ID"),
74
+ });
75
+ export const GetCardStopwatchSchema = z.object({
76
+ id: z.string().describe("Card ID"),
77
+ });
78
+ export const ResetCardStopwatchSchema = z.object({
79
+ id: z.string().describe("Card ID"),
80
+ });
81
+ // Response schemas
82
+ const CardsResponseSchema = z.object({
83
+ items: z.array(PlankaCardSchema),
84
+ included: z.record(z.any()).optional(),
85
+ });
86
+ const CardResponseSchema = z.object({
87
+ item: PlankaCardSchema,
88
+ included: z.record(z.any()).optional(),
89
+ });
90
+ // Function implementations
91
+ /**
92
+ * Creates a new card in a list
93
+ *
94
+ * @param {CreateCardOptions} options - Options for creating the card
95
+ * @param {string} options.listId - The ID of the list to create the card in
96
+ * @param {string} options.name - The name of the card
97
+ * @param {string} [options.description] - The description of the card
98
+ * @param {number} [options.position] - The position of the card in the list (default: 65535)
99
+ * @returns {Promise<object>} The created card
100
+ * @throws {Error} If the card creation fails
101
+ */
102
+ export async function createCard(options) {
103
+ try {
104
+ const response = await plankaRequest(`/api/lists/${options.listId}/cards`, {
105
+ method: "POST",
106
+ body: {
107
+ name: options.name,
108
+ description: options.description,
109
+ position: options.position,
110
+ },
111
+ });
112
+ const parsedResponse = CardResponseSchema.parse(response);
113
+ return parsedResponse.item;
114
+ }
115
+ catch (error) {
116
+ throw new Error(`Failed to create card: ${error instanceof Error ? error.message : String(error)}`);
117
+ }
118
+ }
119
+ /**
120
+ * Retrieves all cards for a specific list
121
+ *
122
+ * @param {string} listId - The ID of the list to get cards from
123
+ * @returns {Promise<Array<object>>} Array of cards in the list
124
+ */
125
+ export async function getCards(listId) {
126
+ try {
127
+ // Get all projects which includes boards
128
+ const projectsResponse = await plankaRequest(`/api/projects`);
129
+ if (!projectsResponse ||
130
+ typeof projectsResponse !== "object" ||
131
+ !("included" in projectsResponse) ||
132
+ !projectsResponse.included ||
133
+ typeof projectsResponse.included !== "object") {
134
+ return [];
135
+ }
136
+ const included = projectsResponse.included;
137
+ // Get all boards
138
+ if (!("boards" in included) || !Array.isArray(included.boards)) {
139
+ return [];
140
+ }
141
+ const boards = included.boards;
142
+ // Check each board for cards with the matching list ID
143
+ for (const board of boards) {
144
+ if (typeof board !== "object" || board === null || !("id" in board)) {
145
+ continue;
146
+ }
147
+ const boardId = board.id;
148
+ // Get the board details which includes cards
149
+ const boardResponse = await plankaRequest(`/api/boards/${boardId}`);
150
+ if (!boardResponse ||
151
+ typeof boardResponse !== "object" ||
152
+ !("included" in boardResponse) ||
153
+ !boardResponse.included ||
154
+ typeof boardResponse.included !== "object") {
155
+ continue;
156
+ }
157
+ const boardIncluded = boardResponse.included;
158
+ if (!("cards" in boardIncluded) ||
159
+ !Array.isArray(boardIncluded.cards)) {
160
+ continue;
161
+ }
162
+ const cards = boardIncluded.cards;
163
+ // Filter cards by list ID
164
+ const matchingCards = cards.filter((card) => typeof card === "object" &&
165
+ card !== null &&
166
+ "listId" in card &&
167
+ card.listId === listId);
168
+ if (matchingCards.length > 0) {
169
+ return matchingCards;
170
+ }
171
+ }
172
+ // If we couldn't find any cards for this list ID
173
+ return [];
174
+ }
175
+ catch (error) {
176
+ // If all else fails, return an empty array
177
+ return [];
178
+ }
179
+ }
180
+ /**
181
+ * Retrieves a specific card by ID
182
+ *
183
+ * @param {string} id - The ID of the card to retrieve
184
+ * @returns {Promise<object>} The requested card
185
+ */
186
+ export async function getCard(id) {
187
+ const response = await plankaRequest(`/api/cards/${id}`);
188
+ const parsedResponse = CardResponseSchema.parse(response);
189
+ return parsedResponse.item;
190
+ }
191
+ /**
192
+ * Updates a card's properties
193
+ *
194
+ * @param {string} id - The ID of the card to update
195
+ * @param {Partial<Omit<CreateCardOptions, "listId">>} options - The properties to update
196
+ * @returns {Promise<object>} The updated card
197
+ */
198
+ export async function updateCard(id, options) {
199
+ const response = await plankaRequest(`/api/cards/${id}`, {
200
+ method: "PATCH",
201
+ body: options,
202
+ });
203
+ const parsedResponse = CardResponseSchema.parse(response);
204
+ return parsedResponse.item;
205
+ }
206
+ /**
207
+ * Moves a card to a different list or position
208
+ *
209
+ * @param {string} cardId - The ID of the card to move
210
+ * @param {string} listId - The ID of the list to move the card to
211
+ * @param {number} [position=65535] - The position in the target list
212
+ * @param {string} [boardId] - The ID of the board (if moving between boards)
213
+ * @param {string} [projectId] - The ID of the project (if moving between projects)
214
+ * @returns {Promise<object>} The moved card
215
+ */
216
+ export async function moveCard(cardId, listId, position = 65535, boardId, projectId) {
217
+ try {
218
+ // Use the PATCH endpoint to update the card with the new list ID and position
219
+ const response = await plankaRequest(`/api/cards/${cardId}`, {
220
+ method: "PATCH",
221
+ body: {
222
+ listId,
223
+ position,
224
+ boardId,
225
+ projectId,
226
+ },
227
+ });
228
+ // Parse and return the updated card
229
+ const parsedResponse = CardResponseSchema.parse(response);
230
+ return parsedResponse.item;
231
+ }
232
+ catch (error) {
233
+ throw new Error(`Failed to move card: ${error instanceof Error ? error.message : String(error)}`);
234
+ }
235
+ }
236
+ /**
237
+ * Duplicates a card in the same list
238
+ *
239
+ * @param {string} id - The ID of the card to duplicate
240
+ * @param {number} [position] - The position for the duplicated card
241
+ * @returns {Promise<object>} The duplicated card
242
+ */
243
+ export async function duplicateCard(id, position) {
244
+ try {
245
+ // First, get the original card to access its name
246
+ const originalCard = await getCard(id);
247
+ // Create a new card with "Copy of" prefix
248
+ const cardName = originalCard ? `Copy of ${originalCard.name}` : "";
249
+ // Get the list ID from the original card
250
+ const listId = originalCard ? originalCard.listId : "";
251
+ if (!listId) {
252
+ throw new Error("Could not determine list ID for card duplication");
253
+ }
254
+ // Create a new card with the same properties but with "Copy of" prefix
255
+ const newCard = await createCard({
256
+ listId,
257
+ name: cardName,
258
+ description: originalCard.description || "",
259
+ position: position || 65535,
260
+ });
261
+ return newCard;
262
+ }
263
+ catch (error) {
264
+ throw new Error(`Failed to duplicate card: ${error instanceof Error ? error.message : String(error)}`);
265
+ }
266
+ }
267
+ /**
268
+ * Deletes a card by ID
269
+ *
270
+ * @param {string} id - The ID of the card to delete
271
+ * @returns {Promise<{success: boolean}>} Success indicator
272
+ */
273
+ export async function deleteCard(id) {
274
+ await plankaRequest(`/api/cards/${id}`, {
275
+ method: "DELETE",
276
+ });
277
+ return { success: true };
278
+ }
279
+ // Stopwatch functions
280
+ /**
281
+ * Starts the stopwatch for a card to track time spent
282
+ *
283
+ * @param {string} id - The ID of the card to start the stopwatch for
284
+ * @returns {Promise<object>} The updated card with stopwatch information
285
+ */
286
+ export async function startCardStopwatch(id) {
287
+ try {
288
+ // Get the current card to check if a stopwatch is already running
289
+ const card = await getCard(id);
290
+ // Calculate the stopwatch object
291
+ let stopwatch = {
292
+ startedAt: new Date().toISOString(),
293
+ total: 0,
294
+ };
295
+ // If there's an existing stopwatch, preserve the total time
296
+ if (card.stopwatch && card.stopwatch.total) {
297
+ stopwatch.total = card.stopwatch.total;
298
+ }
299
+ // Update the card with the new stopwatch
300
+ const response = await plankaRequest(`/api/cards/${id}`, {
301
+ method: "PATCH",
302
+ body: { stopwatch },
303
+ });
304
+ const parsedResponse = CardResponseSchema.parse(response);
305
+ return parsedResponse.item;
306
+ }
307
+ catch (error) {
308
+ throw new Error(`Failed to start card stopwatch: ${error instanceof Error ? error.message : String(error)}`);
309
+ }
310
+ }
311
+ /**
312
+ * Stops the stopwatch for a card
313
+ *
314
+ * @param {string} id - The ID of the card to stop the stopwatch for
315
+ * @returns {Promise<object>} The updated card with stopwatch information
316
+ */
317
+ export async function stopCardStopwatch(id) {
318
+ try {
319
+ // Get the current card to calculate elapsed time
320
+ const card = await getCard(id);
321
+ // If there's no stopwatch or it's not running, return the card as is
322
+ if (!card.stopwatch || !card.stopwatch.startedAt) {
323
+ return card;
324
+ }
325
+ // Calculate elapsed time
326
+ const startedAt = new Date(card.stopwatch.startedAt);
327
+ const now = new Date();
328
+ const elapsedSeconds = Math.floor((now.getTime() - startedAt.getTime()) / 1000);
329
+ // Calculate the new total time
330
+ const totalSeconds = (card.stopwatch.total || 0) + elapsedSeconds;
331
+ // Update the card with the stopped stopwatch (null startedAt but preserved total)
332
+ const stopwatch = {
333
+ startedAt: null,
334
+ total: totalSeconds,
335
+ };
336
+ const response = await plankaRequest(`/api/cards/${id}`, {
337
+ method: "PATCH",
338
+ body: { stopwatch },
339
+ });
340
+ const parsedResponse = CardResponseSchema.parse(response);
341
+ return parsedResponse.item;
342
+ }
343
+ catch (error) {
344
+ throw new Error(`Failed to stop card stopwatch: ${error instanceof Error ? error.message : String(error)}`);
345
+ }
346
+ }
347
+ /**
348
+ * Gets the current stopwatch time for a card
349
+ *
350
+ * @param {string} id - The ID of the card to get the stopwatch time for
351
+ * @returns {Promise<object>} The card's stopwatch information
352
+ */
353
+ export async function getCardStopwatch(id) {
354
+ try {
355
+ const card = await getCard(id);
356
+ // If there's no stopwatch, return default values
357
+ if (!card.stopwatch) {
358
+ return {
359
+ isRunning: false,
360
+ total: 0,
361
+ current: 0,
362
+ formattedTotal: formatDuration(0),
363
+ formattedCurrent: formatDuration(0),
364
+ };
365
+ }
366
+ // Calculate current elapsed time if stopwatch is running
367
+ let currentElapsed = 0;
368
+ const isRunning = !!card.stopwatch.startedAt;
369
+ if (isRunning && card.stopwatch.startedAt) {
370
+ const startedAt = new Date(card.stopwatch.startedAt);
371
+ const now = new Date();
372
+ currentElapsed = Math.floor((now.getTime() - startedAt.getTime()) / 1000);
373
+ }
374
+ return {
375
+ isRunning,
376
+ total: card.stopwatch.total || 0,
377
+ current: currentElapsed,
378
+ startedAt: card.stopwatch.startedAt,
379
+ formattedTotal: formatDuration(card.stopwatch.total || 0),
380
+ formattedCurrent: formatDuration(currentElapsed),
381
+ };
382
+ }
383
+ catch (error) {
384
+ throw new Error(`Failed to get card stopwatch: ${error instanceof Error ? error.message : String(error)}`);
385
+ }
386
+ }
387
+ /**
388
+ * Resets the stopwatch for a card
389
+ *
390
+ * @param {string} id - The ID of the card to reset the stopwatch for
391
+ * @returns {Promise<object>} The updated card with reset stopwatch
392
+ */
393
+ export async function resetCardStopwatch(id) {
394
+ try {
395
+ // Set stopwatch to null to clear it
396
+ const response = await plankaRequest(`/api/cards/${id}`, {
397
+ method: "PATCH",
398
+ body: { stopwatch: null },
399
+ });
400
+ const parsedResponse = CardResponseSchema.parse(response);
401
+ return parsedResponse.item;
402
+ }
403
+ catch (error) {
404
+ throw new Error(`Failed to reset card stopwatch: ${error instanceof Error ? error.message : String(error)}`);
405
+ }
406
+ }
407
+ /**
408
+ * Formats a duration in seconds to a human-readable string
409
+ *
410
+ * @param {number} seconds - The duration in seconds
411
+ * @returns {string} Formatted duration string (e.g., "2h 30m 15s")
412
+ */
413
+ function formatDuration(seconds) {
414
+ const hours = Math.floor(seconds / 3600);
415
+ const minutes = Math.floor((seconds % 3600) / 60);
416
+ const remainingSeconds = seconds % 60;
417
+ let result = "";
418
+ if (hours > 0) {
419
+ result += `${hours}h `;
420
+ }
421
+ if (minutes > 0 || hours > 0) {
422
+ result += `${minutes}m `;
423
+ }
424
+ result += `${remainingSeconds}s`;
425
+ return result.trim();
426
+ }