devops-plugin-kit 0.1.0

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 (112) hide show
  1. package/@danieli-automation/create-devops-plugin/package.json +30 -0
  2. package/@danieli-automation/create-devops-plugin/src/cli.ts +136 -0
  3. package/@danieli-automation/create-devops-plugin/src/index.ts +37 -0
  4. package/@danieli-automation/create-devops-plugin/src/template/files/apiIndexMock.ts +10 -0
  5. package/@danieli-automation/create-devops-plugin/src/template/files/appHtml.ts +25 -0
  6. package/@danieli-automation/create-devops-plugin/src/template/files/appStyles.ts +22 -0
  7. package/@danieli-automation/create-devops-plugin/src/template/files/appTsx.ts +41 -0
  8. package/@danieli-automation/create-devops-plugin/src/template/files/envExample.ts +11 -0
  9. package/@danieli-automation/create-devops-plugin/src/template/files/gitignore.ts +13 -0
  10. package/@danieli-automation/create-devops-plugin/src/template/files/index.ts +51 -0
  11. package/@danieli-automation/create-devops-plugin/src/template/files/packageJson.ts +60 -0
  12. package/@danieli-automation/create-devops-plugin/src/template/files/readme.ts +30 -0
  13. package/@danieli-automation/create-devops-plugin/src/template/files/sdkMock.ts +11 -0
  14. package/@danieli-automation/create-devops-plugin/src/template/files/testSetup.ts +16 -0
  15. package/@danieli-automation/create-devops-plugin/src/template/files/tsconfigJson.ts +30 -0
  16. package/@danieli-automation/create-devops-plugin/src/template/files/vitestConfig.ts +57 -0
  17. package/@danieli-automation/create-devops-plugin/src/template/files/webpackAppConfig.ts +31 -0
  18. package/@danieli-automation/create-devops-plugin/src/template/files/webpackCommonConfig.ts +116 -0
  19. package/@danieli-automation/create-devops-plugin/src/template/files/webpackConfig.ts +15 -0
  20. package/@danieli-automation/create-devops-plugin/src/template/files.ts +1 -0
  21. package/@danieli-automation/create-devops-plugin/src/template/fs.ts +85 -0
  22. package/@danieli-automation/create-devops-plugin/src/template/manifest.ts +40 -0
  23. package/@danieli-automation/create-devops-plugin/src/template/npm.ts +22 -0
  24. package/@danieli-automation/create-devops-plugin/src/template/static/fonts/AzDevMDL2.woff +0 -0
  25. package/@danieli-automation/create-devops-plugin/src/template/static/fonts/bowtie.woff2 +0 -0
  26. package/@danieli-automation/create-devops-plugin/src/template/static/fonts/fabric-icons.woff +0 -0
  27. package/@danieli-automation/create-devops-plugin/src/template/static/fonts/fluent-filled-v1.1.293.woff2 +0 -0
  28. package/@danieli-automation/create-devops-plugin/src/template/static/fonts/fluent-regular-v1.1.293.woff2 +0 -0
  29. package/@danieli-automation/create-devops-plugin/src/template/static/images/DigiMetLogo.jpeg +0 -0
  30. package/@danieli-automation/create-devops-plugin/src/template/static/images/danieliAutomation.png +0 -0
  31. package/@danieli-automation/create-devops-plugin/src/template/static/images/danieliAutomationBlack.jpg +0 -0
  32. package/@danieli-automation/create-devops-plugin/src/template/static/images/danieli_digi_met_logo.jpeg +0 -0
  33. package/@danieli-automation/create-devops-plugin/src/template/static/images/logoSmallpng.png +0 -0
  34. package/@danieli-automation/create-devops-plugin/src/template/types.ts +14 -0
  35. package/@danieli-automation/create-devops-plugin/src/template/utils.ts +22 -0
  36. package/@danieli-automation/create-devops-plugin/tsconfig.json +8 -0
  37. package/@danieli-automation/devops-plugin-core/package.json +27 -0
  38. package/@danieli-automation/devops-plugin-core/src/core/azureClients.ts +18 -0
  39. package/@danieli-automation/devops-plugin-core/src/core/storage/createStore.ts +65 -0
  40. package/@danieli-automation/devops-plugin-core/src/core/storage/hooks/useCrossTeamSprintInstance.ts +145 -0
  41. package/@danieli-automation/devops-plugin-core/src/core/storage/hooks/useTaskOrder.ts +125 -0
  42. package/@danieli-automation/devops-plugin-core/src/core/storage/hooks/useWorkItemOrder.ts +86 -0
  43. package/@danieli-automation/devops-plugin-core/src/core/storage/index.ts +13 -0
  44. package/@danieli-automation/devops-plugin-core/src/core/storage/keys.ts +31 -0
  45. package/@danieli-automation/devops-plugin-core/src/core/storage/repositories/instance.ts +184 -0
  46. package/@danieli-automation/devops-plugin-core/src/core/storage/repositories/taskOrder.ts +59 -0
  47. package/@danieli-automation/devops-plugin-core/src/core/storage/repositories/workItemOrder.ts +60 -0
  48. package/@danieli-automation/devops-plugin-core/src/core/storage/stores.ts +18 -0
  49. package/@danieli-automation/devops-plugin-core/src/core/types/AdoWorkItemType.ts +18 -0
  50. package/@danieli-automation/devops-plugin-core/src/core/types/KVStoreType.ts +1 -0
  51. package/@danieli-automation/devops-plugin-core/src/core/types/ScopeType.ts +1 -0
  52. package/@danieli-automation/devops-plugin-core/src/core/types/SelectedProjectType.ts +8 -0
  53. package/@danieli-automation/devops-plugin-core/src/core/types/SortConfigType.ts +2 -0
  54. package/@danieli-automation/devops-plugin-core/src/core/types/instance/CreateInstanceInputType.ts +10 -0
  55. package/@danieli-automation/devops-plugin-core/src/core/types/instance/CrossSprintInstanceType.ts +20 -0
  56. package/@danieli-automation/devops-plugin-core/src/core/types/instance/DefaultInstanceType.ts +12 -0
  57. package/@danieli-automation/devops-plugin-core/src/core/types/instance/InstanceRowType.ts +18 -0
  58. package/@danieli-automation/devops-plugin-core/src/core/types/instance/UpdateInstanceInputType.ts +10 -0
  59. package/@danieli-automation/devops-plugin-core/src/core/types/taskOrder/TaskOrderMapType.ts +3 -0
  60. package/@danieli-automation/devops-plugin-core/src/core/types/taskOrder/TaskOrderType.ts +1 -0
  61. package/@danieli-automation/devops-plugin-core/src/core/types/workItemOrder/WorkItemOrderMapType.ts +3 -0
  62. package/@danieli-automation/devops-plugin-core/src/core/types/workItemOrder/WorkItemOrderType.ts +1 -0
  63. package/@danieli-automation/devops-plugin-core/src/index.ts +1 -0
  64. package/@danieli-automation/devops-plugin-core/src/pluginCore.ts +12 -0
  65. package/@danieli-automation/devops-plugin-core/tsconfig.json +16 -0
  66. package/@danieli-automation/devops-plugin-features/package.json +31 -0
  67. package/@danieli-automation/devops-plugin-features/src/app/stores/useUIStore.ts +12 -0
  68. package/@danieli-automation/devops-plugin-features/src/app/utils/date.ts +9 -0
  69. package/@danieli-automation/devops-plugin-features/src/app/utils/global.ts +9 -0
  70. package/@danieli-automation/devops-plugin-features/src/core/azureClients.ts +12 -0
  71. package/@danieli-automation/devops-plugin-features/src/features/instances/constants/InstanceConstant.ts +5 -0
  72. package/@danieli-automation/devops-plugin-features/src/features/instances/hooks/useInstancePermission.ts +127 -0
  73. package/@danieli-automation/devops-plugin-features/src/features/instances/stores/__tests__/useInstanceStore.test.ts +25 -0
  74. package/@danieli-automation/devops-plugin-features/src/features/instances/stores/types/InstanceStoreType.ts +7 -0
  75. package/@danieli-automation/devops-plugin-features/src/features/instances/stores/useInstanceStore.ts +9 -0
  76. package/@danieli-automation/devops-plugin-features/src/features/instances/types/CrossSprintInstanceType.ts +20 -0
  77. package/@danieli-automation/devops-plugin-features/src/features/instances/utils/instance.ts +55 -0
  78. package/@danieli-automation/devops-plugin-features/src/features/iterations/api/iterations.ts +298 -0
  79. package/@danieli-automation/devops-plugin-features/src/features/iterations/services/IterationService.ts +215 -0
  80. package/@danieli-automation/devops-plugin-features/src/features/iterations/types/IterationInfoType.ts +15 -0
  81. package/@danieli-automation/devops-plugin-features/src/features/iterations/utils/__tests__/iteration.test.ts +39 -0
  82. package/@danieli-automation/devops-plugin-features/src/features/iterations/utils/iteration.ts +132 -0
  83. package/@danieli-automation/devops-plugin-features/src/features/teams/api/projects.ts +79 -0
  84. package/@danieli-automation/devops-plugin-features/src/features/teams/api/teams.ts +29 -0
  85. package/@danieli-automation/devops-plugin-features/src/features/teams/api/users.ts +124 -0
  86. package/@danieli-automation/devops-plugin-features/src/features/teams/services/ProjectService.ts +80 -0
  87. package/@danieli-automation/devops-plugin-features/src/features/teams/types/SelectedProjectType.ts +8 -0
  88. package/@danieli-automation/devops-plugin-features/src/features/teams/types/TeamMemberType.ts +7 -0
  89. package/@danieli-automation/devops-plugin-features/src/features/teams/types/TeamRowSeedType.ts +6 -0
  90. package/@danieli-automation/devops-plugin-features/src/features/teams/types/UserSearchResultType.ts +8 -0
  91. package/@danieli-automation/devops-plugin-features/src/features/work-items/api/states.ts +24 -0
  92. package/@danieli-automation/devops-plugin-features/src/features/work-items/api/wiql.ts +146 -0
  93. package/@danieli-automation/devops-plugin-features/src/features/work-items/api/workItems.ts +193 -0
  94. package/@danieli-automation/devops-plugin-features/src/features/work-items/constants/DefaultConsant.ts +43 -0
  95. package/@danieli-automation/devops-plugin-features/src/features/work-items/constants/FieldConstant.ts +27 -0
  96. package/@danieli-automation/devops-plugin-features/src/features/work-items/constants/StateConstant.ts +16 -0
  97. package/@danieli-automation/devops-plugin-features/src/features/work-items/constants/TypeConstant.ts +8 -0
  98. package/@danieli-automation/devops-plugin-features/src/features/work-items/services/WIQLService.ts +101 -0
  99. package/@danieli-automation/devops-plugin-features/src/features/work-items/services/WorkItemService.ts +54 -0
  100. package/@danieli-automation/devops-plugin-features/src/features/work-items/types/AdoWorkItemType.ts +18 -0
  101. package/@danieli-automation/devops-plugin-features/src/features/work-items/utils/__tests__/filter.test.ts +87 -0
  102. package/@danieli-automation/devops-plugin-features/src/features/work-items/utils/__tests__/workItem.test.ts +116 -0
  103. package/@danieli-automation/devops-plugin-features/src/features/work-items/utils/filter.ts +189 -0
  104. package/@danieli-automation/devops-plugin-features/src/features/work-items/utils/workItem.ts +245 -0
  105. package/@danieli-automation/devops-plugin-features/src/index.ts +0 -0
  106. package/@danieli-automation/devops-plugin-features/src/test/mocks/azure-devops-extension-api/Work.ts +5 -0
  107. package/@danieli-automation/devops-plugin-features/tsconfig.json +18 -0
  108. package/@danieli-automation/devops-plugin-features/vitest.config.ts +21 -0
  109. package/README.md +55 -0
  110. package/devops-plugin-kit-0.1.0.tgz +0 -0
  111. package/package.json +29 -0
  112. package/tsconfig.base.json +15 -0
@@ -0,0 +1,298 @@
1
+ import { getClient } from "azure-devops-extension-api";
2
+ import { CoreRestClient, TeamContext } from "azure-devops-extension-api/Core";
3
+ import { TeamSettingsIteration } from "azure-devops-extension-api/Work";
4
+ import { witClient, workClient } from "core/azureClients";
5
+ import type { IterationInfoType } from "features/iterations/types/IterationInfoType";
6
+
7
+ /**
8
+ * Fetches the list of iterations for a specific project and team.
9
+ *
10
+ * Uses the Azure DevOps WorkRestClient to retrieve team iterations and transforms
11
+ * the response into a more convenient format with ISO date strings.
12
+ *
13
+ * @param projectId - The unique identifier of the project
14
+ * @param teamId - The unique identifier of the team
15
+ * @returns A promise resolving to an array of IterationInfo objects
16
+ */
17
+ export async function fetchIterations(projectId: string, teamId: string): Promise<IterationInfoType[]> {
18
+ // Fetch TEAM iterations first (fast path)
19
+ let teamIterations: TeamSettingsIteration[] = [];
20
+ try {
21
+ // Resolve the correct team context (ids + names) for Work REST calls.
22
+ const teamContext = await resolveTeamContext(projectId, teamId);
23
+ // Pull team iterations (usually includes dates + paths).
24
+ teamIterations = await workClient().getTeamIterations(teamContext);
25
+ } catch (err) {
26
+ // If team iterations fail, fall back to the classification tree.
27
+ console.warn("Failed to fetch team iterations:", { projectId, teamId, err });
28
+ teamIterations = [];
29
+ }
30
+
31
+ // Normalize/shape team iterations into IterationInfoType and keep only dated entries.
32
+ const fromTeam = teamIterations
33
+ .map((iter) => {
34
+ // Read dates from team iteration attributes.
35
+ const startRaw = iter.attributes?.startDate;
36
+ const finishRaw = iter.attributes?.finishDate;
37
+ // Normalize the path for WIQL compatibility.
38
+ const finalPath = normalizeIterationPath(String(iter.path ?? iter.name ?? ""));
39
+
40
+ return {
41
+ // Use server id when available; fallback to normalized path for stability.
42
+ id: String(iter.id ?? finalPath) as any,
43
+ iterationId: undefined as any,
44
+ timeFrame: iter.attributes?.timeFrame,
45
+ name: String(iter.name ?? ""),
46
+ path: finalPath,
47
+ // Convert dates to ISO string format.
48
+ startDate: startRaw ? new Date(startRaw).toISOString() : undefined,
49
+ finishDate: finishRaw ? new Date(finishRaw).toISOString() : undefined,
50
+ projectId,
51
+ teamId,
52
+ } as IterationInfoType;
53
+ })
54
+ .filter((iter) => {
55
+ // Skip undated iterations (folders or invalid entries).
56
+ if (!iter.startDate || !iter.finishDate) return false;
57
+ // Ensure dates parse cleanly.
58
+ const s = Date.parse(iter.startDate);
59
+ const f = Date.parse(iter.finishDate);
60
+ return Number.isFinite(s) && Number.isFinite(f);
61
+ });
62
+
63
+ // If we got valid iterations from the team API, return them.
64
+ if (fromTeam.length > 0) {
65
+ return dedupeAndSortIterations(fromTeam);
66
+ }
67
+
68
+ // Fallback: classification tree (slower, but fills gaps)
69
+ const rootNodes = await witClient().getRootNodes(projectId, 1);
70
+ // Find the "Iteration" root node.
71
+ const iterationRoot = rootNodes.find((n: any) => n.structureType === 1);
72
+ if (!iterationRoot) return [];
73
+
74
+ // Fetch the classification nodes (tree of all iterations).
75
+ let topLevelNodes: any[] = [];
76
+ try {
77
+ const fetched = await witClient().getClassificationNodes(projectId, [iterationRoot.id], 100);
78
+ topLevelNodes = Array.isArray(fetched) ? fetched : [];
79
+ } catch {
80
+ const fetched = await witClient().getClassificationNodes(projectId, [iterationRoot.id], 1);
81
+ topLevelNodes = Array.isArray(fetched) ? fetched : [];
82
+ }
83
+
84
+ // Walk the tree and collect iteration nodes.
85
+ const iterationNodes: any[] = [];
86
+ const collect = (node: any) => {
87
+ if (!node) return;
88
+ if (node.structureType === 1) iterationNodes.push(node);
89
+ if (Array.isArray(node.children)) {
90
+ for (const c of node.children) collect(c);
91
+ }
92
+ };
93
+ for (const n of topLevelNodes) collect(n);
94
+
95
+ if (iterationNodes.length === 0) return [];
96
+
97
+ // Map classification nodes into IterationInfoType and keep only dated entries.
98
+ const mapped: IterationInfoType[] = iterationNodes
99
+ .map((node: any) => {
100
+ // Normalize the raw node path for WIQL compatibility.
101
+ const nodePathRaw = String(node.path ?? node.name ?? "");
102
+ const finalPath = normalizeIterationPath(nodePathRaw);
103
+ // Read dates from classification node attributes.
104
+ const startRaw = node.attributes?.startDate;
105
+ const finishRaw = node.attributes?.finishDate;
106
+
107
+ return {
108
+ // Prefer node identifier/id; fallback to normalized path.
109
+ id: String(node.identifier ?? node.id ?? finalPath) as any,
110
+ iterationId: node.id,
111
+ timeFrame: undefined,
112
+ name: String(node.name ?? ""),
113
+ path: finalPath,
114
+ // Convert dates to ISO string format.
115
+ startDate: startRaw ? new Date(startRaw).toISOString() : undefined,
116
+ finishDate: finishRaw ? new Date(finishRaw).toISOString() : undefined,
117
+ projectId,
118
+ teamId,
119
+ } as IterationInfoType;
120
+ })
121
+ .filter((iter) => {
122
+ // Skip undated iterations (folders or invalid entries).
123
+ if (!iter.startDate || !iter.finishDate) return false;
124
+ // Ensure dates parse cleanly.
125
+ const s = Date.parse(iter.startDate);
126
+ const f = Date.parse(iter.finishDate);
127
+ return Number.isFinite(s) && Number.isFinite(f);
128
+ });
129
+
130
+ // Deduplicate by canonical path and sort by start date.
131
+ return dedupeAndSortIterations(mapped);
132
+ }
133
+
134
+ /**
135
+ * Fetches team iteration metadata for a project/team pair.
136
+ *
137
+ * Uses the Azure DevOps WorkRestClient to retrieve the team's iterations,
138
+ * normalizes paths, and filters out entries without valid dates.
139
+ *
140
+ * @param projectId - The unique identifier of the project
141
+ * @param teamId - The unique identifier of the team
142
+ * @returns A promise resolving to an array of IterationInfo objects
143
+ */
144
+ export async function fetchIterationsPerWorkItem(projectId: string, teamId: string): Promise<IterationInfoType[]> {
145
+ const teamContext = await resolveTeamContext(projectId, teamId);
146
+ const teamIterations = await workClient().getTeamIterations(teamContext);
147
+
148
+ const teamIterByPath = new Map<string, TeamSettingsIteration>();
149
+ for (const t of teamIterations) {
150
+ if (!t?.path) continue;
151
+ const key = canonicalIterationPath(String(t.path));
152
+ teamIterByPath.set(key, t);
153
+ }
154
+
155
+ const mapped: IterationInfoType[] = teamIterations
156
+ .map((node: any) => {
157
+ const nodePathRaw = String(node.path ?? node.name ?? "");
158
+ const lookupKey = canonicalIterationPath(nodePathRaw);
159
+ const teamIteration = teamIterByPath.get(lookupKey);
160
+ const startRaw = teamIteration?.attributes?.startDate ?? node.attributes?.startDate;
161
+ const finishRaw = teamIteration?.attributes?.finishDate ?? node.attributes?.finishDate;
162
+ const finalPath = normalizeIterationPath(String(teamIteration?.path ?? node.path ?? nodePathRaw));
163
+
164
+ return {
165
+ id: node.id,
166
+ timeFrame: teamIteration?.attributes?.timeFrame,
167
+ name: node.name,
168
+ path: finalPath,
169
+ startDate: startRaw ? new Date(startRaw).toISOString() : undefined,
170
+ finishDate: finishRaw ? new Date(finishRaw).toISOString() : undefined,
171
+ projectId,
172
+ teamId,
173
+ } as IterationInfoType;
174
+ })
175
+ .filter((iter) => {
176
+ if (!iter.startDate || !iter.finishDate) return false;
177
+ const s = Date.parse(iter.startDate);
178
+ const f = Date.parse(iter.finishDate);
179
+ return Number.isFinite(s) && Number.isFinite(f);
180
+ });
181
+ return mapped;
182
+ }
183
+
184
+ /**
185
+ * Canonicalizes an iteration path for reliable comparisons and deduplication.
186
+ *
187
+ * Normalizes slashes, removes leading separators, and strips the "\Iteration\" segment.
188
+ *
189
+ * @param path - Raw iteration path string
190
+ * @returns Canonicalized iteration path string
191
+ */
192
+ export function canonicalIterationPath(path: string): string {
193
+ let p = (path ?? "").trim().toLowerCase();
194
+
195
+ // normalize separators
196
+ p = p.replace(/\//g, "\\");
197
+
198
+ // collapse multiple backslashes
199
+ p = p.replace(/\\\\+/g, "\\");
200
+
201
+ // remove ALL leading backslashes
202
+ p = p.replace(/^\\+/, "");
203
+
204
+ // remove ALL trailing backslashes
205
+ p = p.replace(/\\+$/, "");
206
+
207
+ // remove the "\iteration\" segment if present (case-insensitive because we lowercased)
208
+ p = p.replace(/\\iteration\\/, "\\");
209
+
210
+ return p;
211
+ }
212
+
213
+ /**
214
+ * Compares two iteration paths for equality after canonical normalization.
215
+ *
216
+ * @param a - First iteration path
217
+ * @param b - Second iteration path
218
+ * @returns True if canonicalized paths match; otherwise false.
219
+ */
220
+ export function iterationPathsEqual(a?: string | null, b?: string | null): boolean {
221
+ return canonicalIterationPath(a ?? "") === canonicalIterationPath(b ?? "");
222
+ }
223
+
224
+ //#region Private Functions
225
+ /**
226
+ * Resolves a TeamContext for Work REST calls by looking up project and team names.
227
+ *
228
+ * Falls back to provided ids if names are unavailable.
229
+ *
230
+ * @param projectId - The unique identifier of the project
231
+ * @param teamId - The unique identifier of the team
232
+ * @returns A promise resolving to a TeamContext object
233
+ */
234
+ async function resolveTeamContext(projectId: string, teamId: string): Promise<TeamContext> {
235
+ const core = getClient(CoreRestClient);
236
+
237
+ const project = await core.getProject(projectId);
238
+ const team = await core.getTeam(projectId, teamId);
239
+
240
+ return {
241
+ projectId: projectId,
242
+ teamId: teamId,
243
+ project: project && project.name ? project.name : projectId,
244
+ team: team && team.name ? team.name : teamId
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Normalizes an iteration path for WIQL compatibility.
250
+ *
251
+ * Collapses duplicate backslashes, removes leading separators,
252
+ * and strips the "\Iteration\" segment.
253
+ *
254
+ * @param path - Raw iteration path string
255
+ * @returns Normalized iteration path string
256
+ */
257
+ function normalizeIterationPath(path: string): string {
258
+ let p = (path ?? "").trim();
259
+
260
+ // Convert double slashes to single
261
+ p = p.replace(/\\\\+/g, "\\");
262
+
263
+ // Remove ALL leading backslashes
264
+ p = p.replace(/^\\+/, "");
265
+
266
+ // Some sources include "\Iteration\" explicitly; many queries expect it without that segment.
267
+ // If your WIQL uses [System.IterationPath] = 'Project\...\Sprint', it should NOT contain "\Iteration\".
268
+ p = p.replace(/\\Iteration\\/, "\\");
269
+
270
+ return p;
271
+ }
272
+
273
+ /**
274
+ * Deduplicates iterations by canonical path and sorts them by start date (ascending).
275
+ *
276
+ * Uses iteration name as a secondary tie-breaker when dates match.
277
+ *
278
+ * @param items - Array of IterationInfo objects
279
+ * @returns Deduped and sorted array of IterationInfo objects
280
+ */
281
+ function dedupeAndSortIterations(items: IterationInfoType[]) {
282
+ const byKey = new Map<string, IterationInfoType>();
283
+ for (const it of items) {
284
+ const key = canonicalIterationPath(String(it.path ?? it.name ?? it.id ?? ""));
285
+ if (!byKey.has(key)) byKey.set(key, it);
286
+ }
287
+
288
+ const unique = Array.from(byKey.values());
289
+ unique.sort((a, b) => {
290
+ const aTs = Date.parse(a.startDate ?? "");
291
+ const bTs = Date.parse(b.startDate ?? "");
292
+ if (aTs !== bTs) return aTs - bTs;
293
+ return (a.name ?? "").localeCompare(b.name ?? "");
294
+ });
295
+
296
+ return unique;
297
+ }
298
+ //#endregion
@@ -0,0 +1,215 @@
1
+ import { fetchIterations } from "features/iterations/api/iterations";
2
+ import { IterationInfoType } from "features/iterations/types/IterationInfoType";
3
+ import { SelectedProjectType } from "features/teams/types/SelectedProjectType";
4
+ import { toIsoDate } from "src/app/utils/date";
5
+ import { extractProjectFromPath } from "src/app/utils/global";
6
+
7
+ export const IterationService = {
8
+
9
+ /**
10
+ * @description Filters iterations for the current year (Azure DevOps-like),
11
+ * scoped to the provided project-team pairs.
12
+ *
13
+ * It assumes iteration paths contain the year as a folder segment, e.g.:
14
+ * "MustafaTeamPath/2026/Sprint 1"
15
+ *
16
+ * Rules:
17
+ * - Only iterations that belong to selected project-team pairs
18
+ * - Only iterations whose path includes `/<currentYear>/`
19
+ * - Missing start/finish dates are skipped
20
+ * - Results are sorted by start date ascending
21
+ * - Deduplicated by id (fallback to project/team + dates)
22
+ */
23
+ getIterationsInScope: (iterations: IterationInfoType[], projectTeamPairs: SelectedProjectType[]): IterationInfoType[] => {
24
+
25
+ const year = new Date().getFullYear();
26
+ const yearSegment = `\\${year}\\`;
27
+
28
+ const inCurrentYearFolder = (iter: IterationInfoType) => typeof iter.path === "string" && iter.path.includes(yearSegment);
29
+ const belongsToSelectedPair = (iter: IterationInfoType) => projectTeamPairs.some(p => p.projectId === iter.projectId && p.teamId === iter.teamId);
30
+
31
+ const filtered = iterations.filter(iter => {
32
+ if (!iter.startDate || !iter.finishDate) return false;
33
+ if (!belongsToSelectedPair(iter)) return false;
34
+ if (!inCurrentYearFolder(iter)) return false;
35
+ return true;
36
+ });
37
+
38
+ filtered.sort((a, b) => Date.parse(a.startDate!) - Date.parse(b.startDate!));
39
+
40
+ // Deduplicate
41
+ const seen = new Set<string>();
42
+ const deduped: IterationInfoType[] = [];
43
+ for (const it of filtered) {
44
+ const key = (it.id ? String(it.id) : `${it.projectId}:${it.teamId}:${it.startDate}:${it.finishDate}`) + `@${it.projectId}:${it.teamId}`;
45
+ if (!seen.has(key)) {
46
+ seen.add(key);
47
+ deduped.push(it);
48
+ }
49
+ }
50
+
51
+ return deduped;
52
+ },
53
+
54
+
55
+ /**
56
+ * Gets allowed iteration paths for the selected project-team pairs based on the chosen iteration group's dates.
57
+ *
58
+ * Uses fetchIterations to retrieve iterations for each project-team pair, then filters them to find those
59
+ * that match the start and finish dates of the chosen iteration group.
60
+ *
61
+ * @param selectedProjectPairs - Array of selected project and team ID pairs
62
+ * @param chosenIterationGroup - The iteration group with startDate and finishDate to match against
63
+ * @returns A promise resolving to an array of allowed iteration paths
64
+ */
65
+ getAllowedIterationPathsForSelection: async (selectedProjectPairs: SelectedProjectType[], chosenIterationGroup: { startDate: string; finishDate: string }): Promise<string[]> => {
66
+ if (!Array.isArray(selectedProjectPairs) || selectedProjectPairs.length === 0) return [];
67
+
68
+ const { startDate: chosenStartRaw, finishDate: chosenFinishRaw } = chosenIterationGroup || {};
69
+
70
+ const results = await Promise.all(
71
+ selectedProjectPairs.map(p => fetchIterations(p.projectId, p.teamId))
72
+ );
73
+
74
+ if (!chosenStartRaw || !chosenFinishRaw) return [];
75
+ const chosenStart = Date.parse(chosenStartRaw);
76
+ const chosenFinish = Date.parse(chosenFinishRaw);
77
+ const allIterations = results.flat().filter(Boolean);
78
+
79
+ // pick only iterations whose start/finish match chosenIterationGroup
80
+ const allowed = allIterations
81
+ .filter(iter => iter && iter.startDate && iter.finishDate)
82
+ .filter(iter => {
83
+ const s = Date.parse(iter.startDate!);
84
+ const f = Date.parse(iter.finishDate!);
85
+
86
+ // skip invalid iteration dates
87
+ if (isNaN(s) || isNaN(f)) return false;
88
+
89
+ return s === chosenStart && f === chosenFinish;
90
+ })
91
+ .map(iter => iter.path);
92
+
93
+ return Array.from(new Set(allowed));
94
+ },
95
+
96
+ /**
97
+ * Filters a list of iterations to include only future iterations
98
+ * starting from the **first day of the next month** up to the **end of the current year**.
99
+ *
100
+ * The lower bound is calculated as **00:00 (local time)** on the **1st day of the next month**.
101
+ * The upper bound is calculated as **23:59:59.999 (local time)** on **December 31st of the current year**.
102
+ *
103
+ * An iteration is included if its **startDate** falls within this time window.
104
+ *
105
+ * Invalid iterations are excluded:
106
+ * - Missing startDate or finishDate
107
+ * - Unparseable dates (Date.parse returns NaN)
108
+ * - The current iteration (if provided), matched by id
109
+ *
110
+ * @param iterations - Array of iterations to filter
111
+ * @param currentIteration - The currently active iteration to exclude from the results (optional)
112
+ * @returns A filtered array containing future iterations from next month until the end of the year
113
+ */
114
+ filterFutureIterations: (iterations: IterationInfoType[], currentIteration: IterationInfoType | undefined | null) => {
115
+ const now = new Date();
116
+
117
+ // Start at 00:00 on the 1st day of NEXT month (local time)
118
+ const startOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1).getTime();
119
+
120
+ // End at 23:59:59.999 on Dec 31 of THIS year (local time)
121
+ const endOfYear = new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999).getTime();
122
+
123
+ return iterations.filter(({ id, startDate, finishDate }) => {
124
+ if (!startDate || !finishDate) return false;
125
+
126
+ const start = Date.parse(startDate);
127
+ const end = Date.parse(finishDate);
128
+ if (Number.isNaN(start) || Number.isNaN(end)) return false;
129
+
130
+ // exclude current iteration (if provided)
131
+ if (currentIteration?.id != null && String(currentIteration.id) === String(id)) return false;
132
+
133
+ // include iterations that START between next month start and end of year
134
+ return start >= startOfNextMonth && start <= endOfYear;
135
+ });
136
+ },
137
+
138
+ /**
139
+ * Groups iterations by their start and end dates.
140
+ * Iterations with the same start and end dates are combined into a single entry,
141
+ * with their paths and included projects aggregated.
142
+ *
143
+ * @param iterations Array of IterationInfoType objects
144
+ * @returns Array of grouped IterationInfoType objects
145
+ */
146
+ groupIterationsByStartEnd: (iterations: IterationInfoType[]): IterationInfoType[] => {
147
+ const map = new Map<string, IterationInfoType>();
148
+
149
+ for (const iter of iterations) {
150
+ const projectFromPath = extractProjectFromPath(iter.path);
151
+
152
+ if (iter.startDate && iter.finishDate) {
153
+ const startDate = toIsoDate(iter.startDate);
154
+ const finishDate = toIsoDate(iter.finishDate);
155
+ const key = `DATED__${startDate}__${finishDate}`;
156
+
157
+ const existing = map.get(key);
158
+ if (!existing) {
159
+ map.set(key, {
160
+ ...iter,
161
+ startDate,
162
+ finishDate,
163
+ paths: [iter.path],
164
+ projectsIncludedInIteration: [projectFromPath],
165
+ });
166
+ } else {
167
+ existing.paths = Array.from(new Set([...(existing.paths ?? []), iter.path]));
168
+ existing.projectsIncludedInIteration = Array.from(
169
+ new Set([...(existing.projectsIncludedInIteration ?? []), projectFromPath])
170
+ );
171
+ }
172
+
173
+ continue;
174
+ }
175
+
176
+ const key = `UNDATED__${iter.path}`;
177
+
178
+ const existing = map.get(key);
179
+ if (!existing) {
180
+ map.set(key, {
181
+ ...iter,
182
+ paths: [iter.path],
183
+ projectsIncludedInIteration: [projectFromPath],
184
+ });
185
+ } else {
186
+ existing.paths = Array.from(new Set([...(existing.paths ?? []), iter.path]));
187
+ existing.projectsIncludedInIteration = Array.from(
188
+ new Set([...(existing.projectsIncludedInIteration ?? []), projectFromPath])
189
+ );
190
+ }
191
+ }
192
+
193
+ return Array.from(map.values()).sort((a, b) => {
194
+
195
+ const aHas = a.startDate && a.finishDate;
196
+ const bHas = b.startDate && b.finishDate;
197
+ if (aHas && !bHas) return -1;
198
+ if (!aHas && bHas) return 1;
199
+
200
+ if (aHas && bHas) return String(a.startDate).localeCompare(String(b.startDate));
201
+
202
+ return String(a.path).localeCompare(String(b.path));
203
+ });
204
+ },
205
+
206
+ /**
207
+ * Gets iteration paths from an array of iterations.
208
+ *
209
+ * @param iterations Array of IterationInfoType objects
210
+ * @returns Array of iteration paths as strings
211
+ */
212
+ getIterationPaths: (iterations: IterationInfoType[]): string[] => {
213
+ return iterations.map(iter => iter.path);
214
+ }
215
+ };
@@ -0,0 +1,15 @@
1
+ export type IterationInfoType = {
2
+ id: string;
3
+ iterationId?: number;
4
+ name: string;
5
+ path: string;
6
+ startDate?: string;
7
+ finishDate?: string;
8
+ projectId: string;
9
+ teamId: string;
10
+ paths?: string[];
11
+ timeFrame?: number;
12
+ level1?: string;
13
+ level2?: string;
14
+ projectsIncludedInIteration?: string[];
15
+ };
@@ -0,0 +1,39 @@
1
+ import type { IterationInfoType } from "features/iterations/types/IterationInfoType";
2
+ import { describe, expect, it } from "vitest";
3
+ import { formatIteration } from "../iteration.js";
4
+
5
+ describe("Utils: Iteration Helper Tests: ", () => {
6
+
7
+ //#region formatIteration
8
+ it("formatIteration: returns formatted iteration in specified format if there is any provided iteration", () => {
9
+
10
+ const iteration: IterationInfoType = {
11
+ id: "1",
12
+ name: "Iteration Name",
13
+ path: "Iteration Path",
14
+ startDate: "2026-01-15T00:00:00.000Z",
15
+ finishDate: "2026-01-31T23:59:00.000Z",
16
+ projectId: "ProjectId",
17
+ teamId: "TeamId"
18
+ }
19
+
20
+ expect(formatIteration(iteration)).toBe("2026 Jan (15-31)");
21
+ });
22
+
23
+ it("formatIteration: returns empty string if startDate is invalid", () => {
24
+
25
+ const iteration: IterationInfoType = {
26
+ id: "1",
27
+ name: "Iteration Name",
28
+ path: "Iteration Path",
29
+ startDate: "2026-01-15",
30
+ finishDate: "not-a-date",
31
+ projectId: "ProjectId",
32
+ teamId: "TeamId",
33
+ };
34
+
35
+ expect(formatIteration(iteration as any)).toBe("");
36
+ });
37
+
38
+ //#endregion
39
+ })