ci-cost-diff-action 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.
package/src/cost.js ADDED
@@ -0,0 +1,603 @@
1
+ import { normalizeRateOverrides, resolveRunnerRate } from "./rates.js";
2
+
3
+ /**
4
+ * Workflow job fields consumed from the GitHub Actions jobs API.
5
+ * @typedef {object} GitHubJob
6
+ * @property {string|number} [id] Workflow job id.
7
+ * @property {string} [name] Display name from the workflow job.
8
+ * @property {string[]} [labels] Runner labels attached to the job.
9
+ * @property {string} [started_at] ISO timestamp when the job started.
10
+ * @property {string} [completed_at] ISO timestamp when the job completed.
11
+ * @property {string} [conclusion] Completed job conclusion.
12
+ * @property {string} [status] Current job status, used when conclusion is absent.
13
+ * @property {string} [runner_name] Concrete runner name, when GitHub provides it.
14
+ * @property {string} [runner_group_name] Runner group name, when available.
15
+ * @property {string} [check_run_url] Checks API URL containing the related check run id.
16
+ */
17
+
18
+ /**
19
+ * Cost attribution for a single workflow job.
20
+ * @typedef {object} AnalyzedJob
21
+ * @property {string|number|undefined} id Workflow job id.
22
+ * @property {string} name Job display name.
23
+ * @property {string} conclusion Job conclusion or status fallback.
24
+ * @property {string|null} startedAt Start timestamp, or null when absent.
25
+ * @property {string|null} completedAt Completion timestamp, or null when absent.
26
+ * @property {string[]} labels Runner labels used for SKU inference.
27
+ * @property {string} runnerName Concrete runner name.
28
+ * @property {string} runnerGroupName Runner group name.
29
+ * @property {number} minutes Rounded-up billable minutes.
30
+ * @property {number|null} rate USD-per-minute rate, or null for unknown runners.
31
+ * @property {string} rateSource Source used to choose the rate.
32
+ * @property {string} rateWarning Warning emitted for ambiguous inferred rates.
33
+ * @property {string} sku Billing SKU or normalized override key.
34
+ * @property {number} cost Estimated job cost in USD.
35
+ * @property {string} [runLabel] Report label added when mixing current and baseline jobs.
36
+ */
37
+
38
+ /**
39
+ * Aggregated cost data for jobs with the exact same name.
40
+ * @typedef {object} JobNameSummary
41
+ * @property {string} name Job name used as the aggregation key.
42
+ * @property {number} cost Total cost for matching jobs.
43
+ * @property {number} minutes Total rounded billable minutes.
44
+ * @property {number} runs Number of matching job executions.
45
+ * @property {AnalyzedJob[]} jobs Jobs included in the summary.
46
+ */
47
+
48
+ /**
49
+ * Totals for all analyzed jobs in one workflow run.
50
+ * @typedef {object} AnalysisTotals
51
+ * @property {number} cost Total estimated cost in USD.
52
+ * @property {number} minutes Total rounded billable minutes.
53
+ * @property {number} unknownRunnerCount Count of jobs without a known rate.
54
+ * @property {number} jobCount Number of analyzed jobs.
55
+ */
56
+
57
+ /**
58
+ * Analysis output for one workflow run.
59
+ * @typedef {object} AnalysisSummary
60
+ * @property {AnalyzedJob[]} jobs Jobs included in cost analysis.
61
+ * @property {string[]} skippedJobs Names of jobs skipped because they were excluded or incomplete.
62
+ * @property {Map<string, JobNameSummary>} byJobName Exact-name aggregation map.
63
+ * @property {AnalysisTotals} totals Run-level totals.
64
+ */
65
+
66
+ /**
67
+ * Options that affect job analysis and runner pricing.
68
+ * @typedef {object} AnalyzeOptions
69
+ * @property {string[]} [excludePatterns] Wildcard job-name patterns to skip.
70
+ * @property {string[]} [excludeNames] Exact job names to skip.
71
+ * @property {(string|number)[]} [excludeJobIds] Workflow job ids to skip.
72
+ * @property {Record<string, number|string>|Map<string, number>} [rateOverrides] Runner rate overrides keyed by runner name, group, label, or SKU.
73
+ */
74
+
75
+ /**
76
+ * Per-job-name delta between current and baseline analyses.
77
+ * @typedef {object} JobCostDelta
78
+ * @property {string} name Job name used for exact-name matching.
79
+ * @property {number} currentCost Current run cost for this job name.
80
+ * @property {number} baselineCost Baseline run cost for this job name.
81
+ * @property {number} deltaCost Current minus baseline cost.
82
+ * @property {number|null} deltaPercent Percent cost increase, or null for a zero baseline.
83
+ * @property {number} currentMinutes Current rounded billable minutes.
84
+ * @property {number} baselineMinutes Baseline rounded billable minutes.
85
+ * @property {number} deltaMinutes Current minus baseline rounded minutes.
86
+ * @property {number} currentRuns Current execution count for this job name.
87
+ * @property {number} baselineRuns Baseline execution count for this job name.
88
+ */
89
+
90
+ /**
91
+ * Cost and minute deltas between current and baseline workflow runs.
92
+ * @typedef {object} CostDiff
93
+ * @property {number} currentCost Total current estimated cost in USD.
94
+ * @property {number} baselineCost Total baseline estimated cost in USD.
95
+ * @property {number} deltaCost Current minus baseline cost.
96
+ * @property {number|null} deltaPercent Percent cost increase, or null for a zero baseline.
97
+ * @property {number} currentMinutes Total current rounded billable minutes.
98
+ * @property {number} baselineMinutes Total baseline rounded billable minutes.
99
+ * @property {number} deltaMinutes Current minus baseline rounded minutes.
100
+ * @property {number|null} deltaMinutesPercent Percent minute increase, or null for a zero baseline.
101
+ * @property {JobCostDelta[]} jobs Per-job-name deltas sorted by absolute cost delta.
102
+ */
103
+
104
+ /**
105
+ * Budget gates applied after cost analysis.
106
+ * @typedef {object} ThresholdOptions
107
+ * @property {number} [maxTotalUsd] Fail when current cost exceeds this USD amount.
108
+ * @property {number} [maxIncreaseUsd] Fail when cost delta exceeds this USD amount.
109
+ * @property {number} [maxIncreasePercent] Fail when cost delta percent exceeds this value.
110
+ */
111
+
112
+ /**
113
+ * Result of applying budget gates.
114
+ * @typedef {object} ThresholdResult
115
+ * @property {"pass"|"fail"} conclusion Overall budget result.
116
+ * @property {string[]} failures Human-readable failure messages.
117
+ */
118
+
119
+ const COMPARISON_EPSILON_MULTIPLIER = 8;
120
+
121
+ /**
122
+ * Returns rounded-up billable minutes for a job interval.
123
+ * @param {string|null|undefined} startedAt Start timestamp.
124
+ * @param {string|null|undefined} completedAt Completion timestamp.
125
+ * @returns {number} Zero for missing, invalid, or negative intervals.
126
+ */
127
+ export function roundedMinutes(startedAt, completedAt) {
128
+ if (!startedAt || !completedAt) {
129
+ return 0;
130
+ }
131
+
132
+ const started = new Date(startedAt).getTime();
133
+ const completed = new Date(completedAt).getTime();
134
+
135
+ if (!Number.isFinite(started) || !Number.isFinite(completed) || completed < started) {
136
+ return 0;
137
+ }
138
+
139
+ return Math.max(1, Math.ceil((completed - started) / 60000));
140
+ }
141
+
142
+ /**
143
+ * Checks whether a job name matches any exclusion pattern.
144
+ * @param {string} jobName Job name to test.
145
+ * @param {string[]} [patterns=[]] Wildcard patterns.
146
+ * @returns {boolean} True when the job should be excluded.
147
+ */
148
+ export function shouldExcludeJob(jobName, patterns = []) {
149
+ return patterns.some((pattern) => wildcardMatches(jobName, pattern));
150
+ }
151
+
152
+ function wildcardMatches(value, pattern) {
153
+ const text = String(value).toLowerCase();
154
+ const normalizedPattern = String(pattern).trim().toLowerCase();
155
+ if (normalizedPattern === "") {
156
+ return text === "";
157
+ }
158
+
159
+ const parts = normalizedPattern.split("*");
160
+
161
+ return leadingWildcardMatch(text, normalizedPattern, parts)
162
+ && trailingWildcardMatch(text, normalizedPattern, parts)
163
+ && orderedWildcardPartsMatch(text, parts);
164
+ }
165
+
166
+ function leadingWildcardMatch(text, pattern, parts) {
167
+ return pattern.startsWith("*") || text.startsWith(parts[0] ?? "");
168
+ }
169
+
170
+ function trailingWildcardMatch(text, pattern, parts) {
171
+ return pattern.endsWith("*") || text.endsWith(parts.at(-1) ?? "");
172
+ }
173
+
174
+ function orderedWildcardPartsMatch(text, parts) {
175
+ let offset = 0;
176
+ for (const part of parts.filter(Boolean)) {
177
+ const index = text.indexOf(part, offset);
178
+ if (index === -1) {
179
+ return false;
180
+ }
181
+
182
+ offset = index + part.length;
183
+ }
184
+
185
+ return true;
186
+ }
187
+
188
+ /**
189
+ * Calculates cost attribution for one GitHub Actions job.
190
+ * @param {GitHubJob} job Job from the GitHub workflow jobs API.
191
+ * @param {AnalyzeOptions} [options] Analysis options.
192
+ * @returns {AnalyzedJob} Normalized job cost data.
193
+ */
194
+ export function analyzeJob(job, options = {}) {
195
+ const rateOverrides = rateOverridesFromOptions(options);
196
+ const minutes = roundedMinutes(job.started_at, job.completed_at);
197
+ const rateInfo = resolveRunnerRate(job, rateOverrides);
198
+ const fields = analyzedJobFields(job);
199
+ const cost = jobCost(fields.name, minutes, rateInfo.rate);
200
+
201
+ return {
202
+ id: job.id,
203
+ ...fields,
204
+ minutes,
205
+ rate: rateInfo.rate,
206
+ rateSource: rateInfo.source,
207
+ rateWarning: rateWarning(rateInfo),
208
+ sku: rateInfo.sku,
209
+ cost
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Analyzes a workflow run's jobs and aggregates them by exact job name.
215
+ * @param {GitHubJob[]} jobs Workflow jobs to analyze.
216
+ * @param {AnalyzeOptions} [options] Analysis options.
217
+ * @returns {AnalysisSummary} Per-job and run-level cost summary.
218
+ */
219
+ export function analyzeJobs(jobs, options = {}) {
220
+ const { excludePatterns, excludeNames, excludeJobIds } = exclusionOptions(options);
221
+ const rateOverrides = rateOverridesFromOptions(options);
222
+ const analyzedJobs = [];
223
+ const skippedJobs = [];
224
+
225
+ for (const job of jobs) {
226
+ const skippedJob = skippedJobName(job, excludeJobIds, excludePatterns, excludeNames);
227
+ if (skippedJob !== null) {
228
+ skippedJobs.push(skippedJob);
229
+ continue;
230
+ }
231
+
232
+ analyzedJobs.push(analyzeJob(job, { rateOverrides }));
233
+ }
234
+
235
+ return {
236
+ jobs: analyzedJobs,
237
+ skippedJobs,
238
+ byJobName: groupJobsByName(analyzedJobs),
239
+ totals: analyzeTotals(analyzedJobs)
240
+ };
241
+ }
242
+
243
+ function analyzedJobFields(job) {
244
+ return {
245
+ name: jobName(job),
246
+ conclusion: jobConclusion(job),
247
+ startedAt: job.started_at ?? null,
248
+ completedAt: job.completed_at ?? null,
249
+ labels: jobLabels(job),
250
+ runnerName: job.runner_name ?? "",
251
+ runnerGroupName: job.runner_group_name ?? ""
252
+ };
253
+ }
254
+
255
+ function rateWarning(rateInfo) {
256
+ return rateInfo.warning ?? "";
257
+ }
258
+
259
+ function jobCost(name, minutes, rate) {
260
+ if (typeof rate !== "number") {
261
+ return 0;
262
+ }
263
+
264
+ const cost = minutes * rate;
265
+ if (!Number.isFinite(cost)) {
266
+ throw new Error(`Runner rate for job "${name}" produced a non-finite cost.`);
267
+ }
268
+
269
+ return cost;
270
+ }
271
+
272
+ function exclusionOptions(options) {
273
+ return {
274
+ excludePatterns: options.excludePatterns ?? [],
275
+ excludeNames: new Set(options.excludeNames ?? []),
276
+ excludeJobIds: new Set((options.excludeJobIds ?? []).map((id) => String(id)))
277
+ };
278
+ }
279
+
280
+ function analyzeTotals(jobs) {
281
+ return jobs.reduce((acc, job) => addJobTotals(acc, job), {
282
+ cost: 0,
283
+ minutes: 0,
284
+ unknownRunnerCount: 0,
285
+ jobCount: jobs.length
286
+ });
287
+ }
288
+
289
+ function addJobTotals(acc, job) {
290
+ acc.cost = finiteCostSum(acc.cost, job.cost);
291
+ acc.minutes += job.minutes;
292
+ acc.unknownRunnerCount += unknownRunnerCount(job);
293
+ return acc;
294
+ }
295
+
296
+ function finiteCostSum(left, right) {
297
+ const sum = left + right;
298
+ if (!Number.isFinite(sum)) {
299
+ throw new Error("Analyzed job costs produced a non-finite total.");
300
+ }
301
+
302
+ return sum;
303
+ }
304
+
305
+ function unknownRunnerCount(job) {
306
+ return job.rate === null ? 1 : 0;
307
+ }
308
+
309
+ function groupJobsByName(jobs) {
310
+ const byJobName = new Map();
311
+ for (const job of jobs) {
312
+ addJobToNameGroup(byJobName, job);
313
+ }
314
+
315
+ return byJobName;
316
+ }
317
+
318
+ function addJobToNameGroup(byJobName, job) {
319
+ const existing = byJobName.get(job.name) ?? emptyJobNameSummary(job.name);
320
+
321
+ existing.cost = finiteCostSum(existing.cost, job.cost);
322
+ existing.minutes += job.minutes;
323
+ existing.runs += 1;
324
+ existing.jobs.push(job);
325
+ byJobName.set(job.name, existing);
326
+ }
327
+
328
+ function emptyJobNameSummary(name) {
329
+ return {
330
+ name,
331
+ cost: 0,
332
+ minutes: 0,
333
+ runs: 0,
334
+ jobs: []
335
+ };
336
+ }
337
+
338
+ /**
339
+ * Computes current-versus-baseline cost deltas.
340
+ * @param {AnalysisSummary} current Current run analysis.
341
+ * @param {AnalysisSummary} baseline Baseline run analysis.
342
+ * @returns {CostDiff} Total and per-job-name deltas.
343
+ */
344
+ export function diffSummaries(current, baseline) {
345
+ const currentCost = current.totals.cost;
346
+ const baselineCost = baseline.totals.cost;
347
+ const deltaCost = currentCost - baselineCost;
348
+ const deltaPercent = baselineCost > 0 ? (deltaCost / baselineCost) * 100 : null;
349
+ const currentMinutes = current.totals.minutes;
350
+ const baselineMinutes = baseline.totals.minutes;
351
+ const deltaMinutes = currentMinutes - baselineMinutes;
352
+ const deltaMinutesPercent = baselineMinutes > 0 ? (deltaMinutes / baselineMinutes) * 100 : null;
353
+
354
+ const jobNames = new Set([
355
+ ...current.byJobName.keys(),
356
+ ...baseline.byJobName.keys()
357
+ ]);
358
+
359
+ const jobs = [...jobNames]
360
+ .map((name) => diffJobName(name, current.byJobName, baseline.byJobName))
361
+ .sort((a, b) => Math.abs(b.deltaCost) - Math.abs(a.deltaCost));
362
+
363
+ return {
364
+ currentCost,
365
+ baselineCost,
366
+ deltaCost,
367
+ deltaPercent,
368
+ currentMinutes,
369
+ baselineMinutes,
370
+ deltaMinutes,
371
+ deltaMinutesPercent,
372
+ jobs
373
+ };
374
+ }
375
+
376
+ function rateOverridesFromOptions(options) {
377
+ return normalizeRateOverrides(options.rateOverrides ?? {});
378
+ }
379
+
380
+ function skippedJobName(job, excludeJobIds, excludePatterns, excludeNames) {
381
+ const name = jobName(job);
382
+ if (excludeJobIds.has(String(job.id))) {
383
+ return name;
384
+ }
385
+
386
+ if (excludeNames.has(name)) {
387
+ return name;
388
+ }
389
+
390
+ if (shouldExcludeJob(name, excludePatterns)) {
391
+ return name;
392
+ }
393
+
394
+ return hasValidCompletedInterval(job) ? null : name;
395
+ }
396
+
397
+ function hasValidCompletedInterval(job) {
398
+ if (!job.started_at || !job.completed_at) {
399
+ return false;
400
+ }
401
+
402
+ const started = new Date(job.started_at).getTime();
403
+ const completed = new Date(job.completed_at).getTime();
404
+ return Number.isFinite(started) && Number.isFinite(completed) && completed >= started;
405
+ }
406
+
407
+ function jobName(job) {
408
+ return job.name ?? "Unnamed job";
409
+ }
410
+
411
+ function jobLabels(job) {
412
+ return Array.isArray(job.labels) ? job.labels : [];
413
+ }
414
+
415
+ function jobConclusion(job) {
416
+ return job.conclusion ?? job.status ?? "unknown";
417
+ }
418
+
419
+ function diffJobName(name, currentByJobName, baselineByJobName) {
420
+ const current = jobNameTotals(currentByJobName.get(name));
421
+ const baseline = jobNameTotals(baselineByJobName.get(name));
422
+ const deltaCost = current.cost - baseline.cost;
423
+
424
+ return {
425
+ name,
426
+ currentCost: current.cost,
427
+ baselineCost: baseline.cost,
428
+ deltaCost,
429
+ deltaPercent: baseline.cost > 0 ? (deltaCost / baseline.cost) * 100 : null,
430
+ currentMinutes: current.minutes,
431
+ baselineMinutes: baseline.minutes,
432
+ deltaMinutes: current.minutes - baseline.minutes,
433
+ currentRuns: current.runs,
434
+ baselineRuns: baseline.runs
435
+ };
436
+ }
437
+
438
+ function jobNameTotals(summary) {
439
+ return {
440
+ cost: summary?.cost ?? 0,
441
+ minutes: summary?.minutes ?? 0,
442
+ runs: summary?.runs ?? 0
443
+ };
444
+ }
445
+
446
+ /**
447
+ * Applies configured budget thresholds to a cost diff.
448
+ * @param {CostDiff} diff Current-versus-baseline cost diff.
449
+ * @param {ThresholdOptions} [thresholds] Budget thresholds.
450
+ * @returns {ThresholdResult} Pass/fail conclusion and failure messages.
451
+ */
452
+ export function evaluateThresholds(diff, thresholds = {}) {
453
+ const normalizedThresholds = thresholdOptions(thresholds);
454
+ const failures = [];
455
+ addFailure(failures, totalBudgetFailure(diff, normalizedThresholds.maxTotalUsd));
456
+ addFailure(failures, increaseUsdFailure(diff, normalizedThresholds.maxIncreaseUsd));
457
+ addFailure(failures, increasePercentFailure(diff, normalizedThresholds.maxIncreasePercent));
458
+
459
+ return {
460
+ conclusion: failures.length > 0 ? "fail" : "pass",
461
+ failures
462
+ };
463
+ }
464
+
465
+ function thresholdOptions(thresholds) {
466
+ return {
467
+ maxTotalUsd: thresholdValue(thresholds.maxTotalUsd, "maxTotalUsd"),
468
+ maxIncreaseUsd: thresholdValue(thresholds.maxIncreaseUsd, "maxIncreaseUsd"),
469
+ maxIncreasePercent: thresholdValue(thresholds.maxIncreasePercent, "maxIncreasePercent")
470
+ };
471
+ }
472
+
473
+ function thresholdValue(value, name) {
474
+ if (value === undefined || value === null || value === "") {
475
+ return undefined;
476
+ }
477
+
478
+ if (!Number.isFinite(value)) {
479
+ throw new Error(`${name} must be a finite non-negative number.`);
480
+ }
481
+
482
+ if (value < 0) {
483
+ throw new Error(`${name} must be a non-negative number.`);
484
+ }
485
+
486
+ return value;
487
+ }
488
+
489
+ function addFailure(failures, failure) {
490
+ if (failure) {
491
+ failures.push(failure);
492
+ }
493
+ }
494
+
495
+ function totalBudgetFailure(diff, maxTotalUsd) {
496
+ if (Number.isFinite(maxTotalUsd) && !Number.isFinite(diff.currentCost)) {
497
+ return "current estimated cost is non-finite; check runner rates.";
498
+ }
499
+
500
+ if (!exceedsThreshold(diff.currentCost, maxTotalUsd)) {
501
+ return "";
502
+ }
503
+
504
+ return `current estimated cost ${formatUsd(diff.currentCost)} is above total budget ${formatUsd(maxTotalUsd)}`;
505
+ }
506
+
507
+ function increaseUsdFailure(diff, maxIncreaseUsd) {
508
+ if (Number.isFinite(maxIncreaseUsd) && !Number.isFinite(diff.deltaCost)) {
509
+ return "estimated cost increase is non-finite; check runner rates.";
510
+ }
511
+
512
+ if (!exceedsThreshold(diff.deltaCost, maxIncreaseUsd)) {
513
+ return "";
514
+ }
515
+
516
+ return `estimated cost increased by ${formatUsd(diff.deltaCost)}, above ${formatUsd(maxIncreaseUsd)}`;
517
+ }
518
+
519
+ function increasePercentFailure(diff, maxIncreasePercent) {
520
+ if (!Number.isFinite(maxIncreasePercent)) {
521
+ return "";
522
+ }
523
+
524
+ if (diff.deltaPercent === null) {
525
+ return zeroBaselinePercentFailure(diff);
526
+ }
527
+
528
+ if (!Number.isFinite(diff.deltaPercent)) {
529
+ return "estimated cost increase percentage is non-finite; check runner rates.";
530
+ }
531
+
532
+ if (exceedsThreshold(diff.deltaPercent, maxIncreasePercent)) {
533
+ return `estimated cost increased by ${formatPercent(diff.deltaPercent)}, above ${formatPercent(maxIncreasePercent)}`;
534
+ }
535
+
536
+ return "";
537
+ }
538
+
539
+ function zeroBaselinePercentFailure(diff) {
540
+ if (diff.baselineCost !== 0 || diff.currentCost <= 0) {
541
+ return "";
542
+ }
543
+
544
+ return `estimated cost increased from $0.00 to ${formatUsd(diff.currentCost)}; percentage increase cannot be computed against a zero baseline`;
545
+ }
546
+
547
+ function exceedsThreshold(value, threshold) {
548
+ if (!Number.isFinite(value) || !Number.isFinite(threshold)) {
549
+ return false;
550
+ }
551
+
552
+ return value - threshold > comparisonTolerance(value, threshold);
553
+ }
554
+
555
+ function comparisonTolerance(value, threshold) {
556
+ return Number.EPSILON * COMPARISON_EPSILON_MULTIPLIER * Math.max(1, Math.abs(value), Math.abs(threshold));
557
+ }
558
+
559
+ /**
560
+ * Formats USD values for report output.
561
+ * @param {number} value USD value.
562
+ * @returns {string} Dollar-formatted value.
563
+ */
564
+ export function formatUsd(value) {
565
+ const abs = Math.abs(value);
566
+ const digits = usdDigits(abs);
567
+ const rounded = Number(abs.toFixed(digits));
568
+ if (isTinyNonzeroUsd(abs, rounded)) {
569
+ return `${usdSign(value)}<$0.0001`;
570
+ }
571
+
572
+ return `${usdSign(value)}$${rounded.toFixed(digits)}`;
573
+ }
574
+
575
+ function usdDigits(abs) {
576
+ return abs > 0 && abs < 0.01 ? 4 : 2;
577
+ }
578
+
579
+ function isTinyNonzeroUsd(abs, rounded) {
580
+ return abs > 0 && rounded === 0;
581
+ }
582
+
583
+ function usdSign(value) {
584
+ return value < 0 ? "-" : "";
585
+ }
586
+
587
+ /**
588
+ * Formats percentage values for report output.
589
+ * @param {number|null|undefined} value Percentage value.
590
+ * @returns {string} Signed percentage or `n/a`.
591
+ */
592
+ export function formatPercent(value) {
593
+ if (value === null || value === undefined || !Number.isFinite(value)) {
594
+ return "n/a";
595
+ }
596
+
597
+ const rounded = Number(value.toFixed(1));
598
+ if (rounded === 0) {
599
+ return "0.0%";
600
+ }
601
+
602
+ return `${rounded > 0 ? "+" : ""}${rounded.toFixed(1)}%`;
603
+ }