logwork-cli 0.0.0-stage → 1.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 (96) hide show
  1. package/LICENSE +11 -0
  2. package/README.md +330 -2
  3. package/RELEASE.md +89 -0
  4. package/bin/logwork.mjs +104 -0
  5. package/commands/auth.mjs +227 -0
  6. package/commands/commits.mjs +82 -0
  7. package/commands/delete.mjs +69 -0
  8. package/commands/doctor.mjs +24 -0
  9. package/commands/edit.mjs +88 -0
  10. package/commands/history.mjs +62 -0
  11. package/commands/query.mjs +52 -0
  12. package/commands/quick.mjs +163 -0
  13. package/commands/reconcile.mjs +55 -0
  14. package/commands/release.mjs +77 -0
  15. package/commands/reminder.mjs +172 -0
  16. package/commands/setup.mjs +508 -0
  17. package/commands/skill.mjs +91 -0
  18. package/commands/tui.mjs +50 -0
  19. package/commands/update.mjs +161 -0
  20. package/docs/advanced.md +334 -0
  21. package/docs/mcp-setup.md +368 -0
  22. package/docs/operations-guide.md +587 -0
  23. package/docs/recipes.md +259 -0
  24. package/docs/security.md +151 -0
  25. package/examples/mcp/antigravity.mcp_config.json +8 -0
  26. package/examples/mcp/codex.config.toml +5 -0
  27. package/examples/mcp/copilot.mcp.json +9 -0
  28. package/lib/agent-skill.mjs +16 -0
  29. package/lib/api-auth.mjs +1259 -0
  30. package/lib/api.mjs +1422 -0
  31. package/lib/apply-ledger.mjs +227 -0
  32. package/lib/atomic-file.mjs +24 -0
  33. package/lib/auth-diagnostics.mjs +113 -0
  34. package/lib/auth-errors.mjs +22 -0
  35. package/lib/auth-redaction.mjs +78 -0
  36. package/lib/auth-terminal.mjs +97 -0
  37. package/lib/auth.mjs +323 -0
  38. package/lib/batch-parser.mjs +145 -0
  39. package/lib/batch-workflow.mjs +842 -0
  40. package/lib/commit-workflow.mjs +402 -0
  41. package/lib/config.mjs +191 -0
  42. package/lib/credential-store.mjs +130 -0
  43. package/lib/diagnostics.mjs +307 -0
  44. package/lib/entrypoint.mjs +14 -0
  45. package/lib/file-lock.mjs +44 -0
  46. package/lib/http.mjs +125 -0
  47. package/lib/jira-api.mjs +258 -0
  48. package/lib/jira-auth.mjs +203 -0
  49. package/lib/jira-workflow.mjs +523 -0
  50. package/lib/keychain.mjs +105 -0
  51. package/lib/legacy-migration.mjs +66 -0
  52. package/lib/list-navigation.mjs +19 -0
  53. package/lib/logwork-config.mjs +199 -0
  54. package/lib/logwork-f03-enrich.mjs +41 -0
  55. package/lib/macos.mjs +33 -0
  56. package/lib/manual-apply-state.mjs +35 -0
  57. package/lib/manual-commands.mjs +294 -0
  58. package/lib/manual-drafts.mjs +154 -0
  59. package/lib/manual-ink-app.mjs +1654 -0
  60. package/lib/manual-ink-auth.mjs +349 -0
  61. package/lib/manual-ink-ui.mjs +675 -0
  62. package/lib/manual-input.mjs +21 -0
  63. package/lib/manual-logwork-wizard.mjs +310 -0
  64. package/lib/manual-mcp.mjs +187 -0
  65. package/lib/manual-repl.mjs +1037 -0
  66. package/lib/manual-targets.mjs +78 -0
  67. package/lib/mcp-server.mjs +833 -0
  68. package/lib/os-notification.mjs +105 -0
  69. package/lib/package-info.mjs +17 -0
  70. package/lib/paths.mjs +68 -0
  71. package/lib/project-identity.mjs +66 -0
  72. package/lib/project-mapping-workflow.mjs +309 -0
  73. package/lib/project-resolver.mjs +373 -0
  74. package/lib/query-workflow.mjs +553 -0
  75. package/lib/reconciliation-workflow.mjs +446 -0
  76. package/lib/release-doctor.mjs +448 -0
  77. package/lib/reminder-config.mjs +71 -0
  78. package/lib/reminder-scheduler.mjs +301 -0
  79. package/lib/reminder-service.mjs +118 -0
  80. package/lib/reminder-state.mjs +38 -0
  81. package/lib/reminder-workflow.mjs +299 -0
  82. package/lib/ro-delete-args.mjs +38 -0
  83. package/lib/ro-delete-workflow.mjs +115 -0
  84. package/lib/ro-edit-args.mjs +142 -0
  85. package/lib/ro-edit-workflow.mjs +632 -0
  86. package/lib/ro-entry-reader.mjs +56 -0
  87. package/lib/ro-resubmit-workflow.mjs +134 -0
  88. package/lib/terminal-auth-prompts.mjs +120 -0
  89. package/lib/ui.mjs +133 -0
  90. package/lib/update-service.mjs +350 -0
  91. package/lib/util.mjs +66 -0
  92. package/lib/windows-credential-manager.mjs +260 -0
  93. package/lib/worklog-task-resolver.mjs +113 -0
  94. package/package.json +73 -4
  95. package/setup.sh +22 -0
  96. package/skills/logwork/SKILL.md +43 -0
@@ -0,0 +1,446 @@
1
+ import { ApiError } from './api.mjs';
2
+ import {
3
+ getJiraIssueWorklogs,
4
+ searchJiraIssues
5
+ } from './jira-api.mjs';
6
+ import {
7
+ createJiraAuthRequiredError,
8
+ getStoredJiraSession
9
+ } from './jira-auth.mjs';
10
+ import {
11
+ normalizeQueryRange,
12
+ queryLogwork
13
+ } from './query-workflow.mjs';
14
+
15
+ export const RECONCILIATION_PERIODS = [
16
+ 'today',
17
+ 'yesterday',
18
+ 'this_week',
19
+ 'last_week',
20
+ 'this_month',
21
+ 'last_month'
22
+ ];
23
+
24
+ const ISSUE_KEY_REGEX = /\b[A-Z][A-Z0-9]+-\d+\b/g;
25
+
26
+ export async function reconcileLogwork({
27
+ period = 'this_week',
28
+ cwd = process.cwd(),
29
+ queryRo = queryLogwork,
30
+ queryJira = queryJiraWorklogs
31
+ } = {}) {
32
+ const normalizedPeriod = normalizeReconciliationPeriod(period);
33
+ const range = normalizeQueryRange({ period: normalizedPeriod });
34
+ const [ro, jira] = await Promise.all([
35
+ queryRo({
36
+ period: normalizedPeriod,
37
+ cwd,
38
+ includeEntries: true
39
+ }),
40
+ queryJira({ range })
41
+ ]);
42
+ const days = buildReconciliationDays({ ro, jira });
43
+ const totals = buildReconciliationTotals({ ro, jira, days });
44
+ const suggestions = days.flatMap((day) => day.suggestions);
45
+
46
+ return {
47
+ status: totals.mismatchDayCount ? 'mismatch' : 'matched',
48
+ period: normalizedPeriod,
49
+ range,
50
+ totals,
51
+ days,
52
+ suggestions,
53
+ summary: formatReconciliationSummary({
54
+ period: normalizedPeriod,
55
+ range,
56
+ totals,
57
+ days,
58
+ suggestions
59
+ })
60
+ };
61
+ }
62
+
63
+ export async function queryJiraWorklogs({
64
+ range,
65
+ getSession = getStoredJiraSession,
66
+ searchIssues = searchJiraIssues,
67
+ fetchWorklogs
68
+ } = {}) {
69
+ assertRange(range);
70
+ const session = await getSession();
71
+ const currentUser = session.user;
72
+ if (!hasJiraUserIdentity(currentUser)) {
73
+ throw createJiraAuthRequiredError('Stored Jira session has no user identity. Run `logwork login jira` again.');
74
+ }
75
+ const issueSearcher = searchIssues;
76
+ const worklogFetcher = fetchWorklogs || ((issueKey) => getJiraIssueWorklogs(session.token, issueKey, {
77
+ baseUrl: session.baseUrl
78
+ }));
79
+ const jql = `worklogAuthor = currentUser() AND worklogDate >= "${range.from}" AND worklogDate < "${range.to}"`;
80
+
81
+ try {
82
+ const issues = await issueSearcher(session.token, {
83
+ jql,
84
+ fields: ['key', 'summary'],
85
+ pageSize: 100
86
+ }, {
87
+ baseUrl: session.baseUrl
88
+ });
89
+ const worklogGroups = await mapWithConcurrency(issues, 5, async (issue) => ({
90
+ issue,
91
+ worklogs: await worklogFetcher(issue.key)
92
+ }));
93
+ const entries = worklogGroups.flatMap(({ issue, worklogs }) => (
94
+ (worklogs || [])
95
+ .filter((worklog) => isCurrentUserWorklog(worklog, currentUser, range))
96
+ .map((worklog) => normalizeJiraReconciliationEntry(issue, worklog))
97
+ )).sort(compareEntries);
98
+
99
+ return {
100
+ range,
101
+ issueCount: issues.length,
102
+ worklogCount: entries.length,
103
+ hours: roundHours(entries.reduce((sum, entry) => sum + entry.hours, 0)),
104
+ entries,
105
+ issues: issues.map((issue) => ({
106
+ key: issue.key,
107
+ summary: issue.summary
108
+ }))
109
+ };
110
+ } catch (error) {
111
+ if (error instanceof ApiError && error.status === 401) {
112
+ throw createJiraAuthRequiredError();
113
+ }
114
+ throw error;
115
+ }
116
+ }
117
+
118
+ export function buildReconciliationDays({ ro = {}, jira = {} } = {}) {
119
+ const roEntries = Array.isArray(ro.entries) ? ro.entries.map(normalizeRoEntry) : [];
120
+ const jiraEntries = Array.isArray(jira.entries) ? jira.entries : [];
121
+ const roDays = new Map((ro.days || []).map((day) => [day.date, day]));
122
+ const dates = new Set([
123
+ ...roDays.keys(),
124
+ ...roEntries.map((entry) => entry.date),
125
+ ...jiraEntries.map((entry) => entry.date)
126
+ ]);
127
+
128
+ return [...dates].sort().map((date) => {
129
+ const dayRoEntries = roEntries.filter((entry) => entry.date === date);
130
+ const dayJiraEntries = jiraEntries.filter((entry) => entry.date === date);
131
+ const detailedRoHours = roundHours(dayRoEntries.reduce((sum, entry) => sum + entry.hours, 0));
132
+ const roHours = roundHours(roDays.get(date)?.loggedHours ?? detailedRoHours);
133
+ const jiraHours = roundHours(dayJiraEntries.reduce((sum, entry) => sum + entry.hours, 0));
134
+ const bookedHours = roundHours(roDays.get(date)?.bookedHours || 0);
135
+ const unverifiedRoHours = roundHours(Math.max(0, roHours - detailedRoHours));
136
+ const issueComparisons = buildIssueComparisons(dayRoEntries, dayJiraEntries);
137
+ const suggestions = buildCorrectionSuggestions(date, issueComparisons);
138
+ const unmatchedRoEntries = dayRoEntries.filter((entry) => entry.issueKeys.length !== 1);
139
+ const differenceHours = roundHours(jiraHours - roHours);
140
+
141
+ return {
142
+ date,
143
+ bookedHours,
144
+ roHours,
145
+ jiraHours,
146
+ differenceHours,
147
+ missingOn: differenceHours < 0 ? 'jira' : differenceHours > 0 ? 'ro' : null,
148
+ remainingBookedHours: roundHours(Math.max(0, bookedHours - roHours)),
149
+ status: reconciliationDayStatus({
150
+ bookedHours,
151
+ roHours,
152
+ jiraHours,
153
+ issueComparisons,
154
+ unmatchedRoEntries,
155
+ unverifiedRoHours
156
+ }),
157
+ roEntries: dayRoEntries,
158
+ jiraEntries: dayJiraEntries,
159
+ issueComparisons,
160
+ unmatchedRoEntries,
161
+ unverifiedRoHours,
162
+ suggestions
163
+ };
164
+ });
165
+ }
166
+
167
+ export function formatReconciliationSummary({
168
+ period,
169
+ range,
170
+ totals,
171
+ days,
172
+ suggestions = []
173
+ } = {}) {
174
+ const lines = [
175
+ `RO / Jira reconciliation: ${formatPeriod(period)} (${range.from} to ${range.to}).`,
176
+ '',
177
+ 'Date Booked RO Jira Diff Status'
178
+ ];
179
+ if (!days.length) {
180
+ lines.push('No Resource Optimiser or Jira worklogs found.');
181
+ }
182
+ for (const day of days) {
183
+ lines.push([
184
+ day.date,
185
+ `${formatHours(day.bookedHours)}h`.padStart(7),
186
+ `${formatHours(day.roHours)}h`.padStart(7),
187
+ `${formatHours(day.jiraHours)}h`.padStart(7),
188
+ formatSignedHours(day.differenceHours).padStart(7),
189
+ formatDayStatus(day.status)
190
+ ].join(' '));
191
+ }
192
+ lines.push('');
193
+ lines.push(`Total: booked ${formatHours(totals.bookedHours)}h, RO ${formatHours(totals.roHours)}h, Jira ${formatHours(totals.jiraHours)}h, difference ${formatSignedHours(totals.differenceHours)}.`);
194
+ lines.push(`Days: matched ${totals.matchedDayCount}, mismatched ${totals.mismatchDayCount}, incomplete ${totals.incompleteDayCount}, unverified ${totals.unverifiedDayCount}.`);
195
+
196
+ if (suggestions.length) {
197
+ lines.push('');
198
+ lines.push('High-confidence correction suggestions:');
199
+ for (const suggestion of suggestions) {
200
+ lines.push(`- ${suggestion.date}: ${suggestion.target.toUpperCase()} +${formatHours(suggestion.hours)}h ${suggestion.issueKey} - ${suggestion.taskName}`);
201
+ }
202
+ lines.push('Create a normal target-specific preview before applying any suggestion.');
203
+ }
204
+
205
+ const unverifiedCount = days.reduce((sum, day) => sum + day.unmatchedRoEntries.length, 0);
206
+ const missingDetailHours = roundHours(days.reduce((sum, day) => sum + day.unverifiedRoHours, 0));
207
+ if (unverifiedCount || missingDetailHours) {
208
+ lines.push('');
209
+ if (unverifiedCount) {
210
+ lines.push(`${unverifiedCount} RO entr${unverifiedCount === 1 ? 'y has' : 'ies have'} no single Jira issue key, so task-level reconciliation is unverified.`);
211
+ }
212
+ if (missingDetailHours) {
213
+ lines.push(`${formatHours(missingDetailHours)}h of RO work has no task detail returned, so task-level reconciliation is unverified.`);
214
+ }
215
+ }
216
+ return lines.join('\n');
217
+ }
218
+
219
+ export function normalizeReconciliationPeriod(value = 'this_week') {
220
+ const normalized = String(value || 'this_week').trim().toLowerCase().replace(/-/g, '_');
221
+ const aliases = {
222
+ week: 'this_week',
223
+ month: 'this_month'
224
+ };
225
+ const period = aliases[normalized] || normalized;
226
+ if (!RECONCILIATION_PERIODS.includes(period)) {
227
+ throw new Error('Reconciliation period must be today, yesterday, this-week, last-week, this-month, or last-month.');
228
+ }
229
+ return period;
230
+ }
231
+
232
+ function buildReconciliationTotals({ ro, jira, days }) {
233
+ const bookedHours = roundHours(ro?.totals?.bookedHours ?? days.reduce((sum, day) => sum + day.bookedHours, 0));
234
+ const roHours = roundHours(ro?.totals?.loggedHours ?? days.reduce((sum, day) => sum + day.roHours, 0));
235
+ const jiraHours = roundHours(jira?.hours ?? days.reduce((sum, day) => sum + day.jiraHours, 0));
236
+ return {
237
+ bookedHours,
238
+ roHours,
239
+ jiraHours,
240
+ differenceHours: roundHours(jiraHours - roHours),
241
+ dayCount: days.length,
242
+ matchedDayCount: days.filter((day) => day.status.startsWith('matched')).length,
243
+ mismatchDayCount: days.filter((day) => ['missing_jira', 'missing_ro', 'entry_mismatch'].includes(day.status)).length,
244
+ incompleteDayCount: days.filter((day) => day.remainingBookedHours > 0).length,
245
+ unverifiedDayCount: days.filter((day) => day.status === 'matched_unverified').length,
246
+ suggestionCount: days.reduce((sum, day) => sum + day.suggestions.length, 0)
247
+ };
248
+ }
249
+
250
+ function buildIssueComparisons(roEntries, jiraEntries) {
251
+ const issueKeys = new Set([
252
+ ...roEntries.flatMap((entry) => entry.issueKeys.length === 1 ? entry.issueKeys : []),
253
+ ...jiraEntries.map((entry) => entry.issueKey).filter(Boolean)
254
+ ]);
255
+ return [...issueKeys].sort().map((issueKey) => {
256
+ const matchingRo = roEntries.filter((entry) => entry.issueKeys.length === 1 && entry.issueKeys[0] === issueKey);
257
+ const matchingJira = jiraEntries.filter((entry) => entry.issueKey === issueKey);
258
+ const roHours = roundHours(matchingRo.reduce((sum, entry) => sum + entry.hours, 0));
259
+ const jiraHours = roundHours(matchingJira.reduce((sum, entry) => sum + entry.hours, 0));
260
+ return {
261
+ issueKey,
262
+ roHours,
263
+ jiraHours,
264
+ differenceHours: roundHours(jiraHours - roHours),
265
+ roEntries: matchingRo,
266
+ jiraEntries: matchingJira
267
+ };
268
+ });
269
+ }
270
+
271
+ function buildCorrectionSuggestions(date, comparisons) {
272
+ return comparisons.flatMap((comparison) => {
273
+ if (comparison.differenceHours === 0) {
274
+ return [];
275
+ }
276
+ const target = comparison.differenceHours < 0 ? 'jira' : 'ro';
277
+ const sourceEntries = target === 'jira' ? comparison.roEntries : comparison.jiraEntries;
278
+ const taskName = preferredTaskName(sourceEntries, comparison.issueKey);
279
+ return [{
280
+ date,
281
+ target,
282
+ issueKey: comparison.issueKey,
283
+ hours: roundHours(Math.abs(comparison.differenceHours)),
284
+ taskName,
285
+ confidence: 'high',
286
+ requiresProjectResolution: target === 'ro'
287
+ }];
288
+ });
289
+ }
290
+
291
+ function preferredTaskName(entries, issueKey) {
292
+ const entry = [...entries].sort((left, right) => right.hours - left.hours)[0];
293
+ const taskName = String(entry?.taskName || entry?.issueSummary || '').trim();
294
+ return taskName || issueKey;
295
+ }
296
+
297
+ function reconciliationDayStatus({
298
+ bookedHours,
299
+ roHours,
300
+ jiraHours,
301
+ issueComparisons,
302
+ unmatchedRoEntries,
303
+ unverifiedRoHours
304
+ }) {
305
+ const difference = roundHours(jiraHours - roHours);
306
+ if (difference < 0) {
307
+ return 'missing_jira';
308
+ }
309
+ if (difference > 0) {
310
+ return 'missing_ro';
311
+ }
312
+ if (issueComparisons.some((comparison) => comparison.differenceHours !== 0)) {
313
+ return 'entry_mismatch';
314
+ }
315
+ if ((unmatchedRoEntries.length || unverifiedRoHours > 0) && (roHours > 0 || jiraHours > 0)) {
316
+ return 'matched_unverified';
317
+ }
318
+ if (bookedHours > roHours) {
319
+ return 'matched_incomplete';
320
+ }
321
+ return 'matched';
322
+ }
323
+
324
+ function normalizeRoEntry(entry = {}) {
325
+ const taskName = String(entry.taskName || '');
326
+ return {
327
+ id: entry.id,
328
+ date: String(entry.date || ''),
329
+ projectMemberId: entry.projectMemberId,
330
+ projectId: entry.projectId,
331
+ projectName: String(entry.projectName || ''),
332
+ hours: roundHours(entry.hours),
333
+ taskName,
334
+ issueKeys: extractIssueKeys(taskName)
335
+ };
336
+ }
337
+
338
+ function normalizeJiraReconciliationEntry(issue, worklog) {
339
+ return {
340
+ id: worklog.id,
341
+ date: normalizeDateOnly(worklog.started),
342
+ issueKey: issue.key,
343
+ issueSummary: issue.summary || '',
344
+ hours: roundHours(Number(worklog.timeSpentSeconds || 0) / 3600),
345
+ timeSpentSeconds: Number(worklog.timeSpentSeconds || 0),
346
+ taskName: stripJiraWorklogMarkers(worklog.comment) || issue.summary || issue.key,
347
+ comment: String(worklog.comment || ''),
348
+ started: String(worklog.started || '')
349
+ };
350
+ }
351
+
352
+ function isCurrentUserWorklog(worklog, currentUser, range) {
353
+ const date = normalizeDateOnly(worklog.started);
354
+ return date >= range.from && date < range.to && sameJiraUser(worklog.author, currentUser);
355
+ }
356
+
357
+ function sameJiraUser(left, right) {
358
+ for (const field of ['key', 'name', 'emailAddress']) {
359
+ const leftValue = normalizeIdentity(left?.[field]);
360
+ const rightValue = normalizeIdentity(right?.[field]);
361
+ if (leftValue && rightValue && leftValue === rightValue) {
362
+ return true;
363
+ }
364
+ }
365
+ const leftDisplay = normalizeIdentity(left?.displayName);
366
+ const rightDisplay = normalizeIdentity(right?.displayName);
367
+ return Boolean(leftDisplay && rightDisplay && leftDisplay === rightDisplay);
368
+ }
369
+
370
+ function hasJiraUserIdentity(user) {
371
+ return ['key', 'name', 'emailAddress', 'displayName'].some((field) => normalizeIdentity(user?.[field]));
372
+ }
373
+
374
+ function extractIssueKeys(value) {
375
+ return [...new Set(String(value || '').toUpperCase().match(ISSUE_KEY_REGEX) || [])];
376
+ }
377
+
378
+ function stripJiraWorklogMarkers(value) {
379
+ return String(value || '')
380
+ .replace(/\s*#lh:[a-f0-9]{6,16}\s*/gi, ' ')
381
+ .replace(/\s*\[logwork-helper:[^\]]+\]\s*/gi, ' ')
382
+ .replace(/\s+/g, ' ')
383
+ .trim();
384
+ }
385
+
386
+ async function mapWithConcurrency(items, concurrency, mapper) {
387
+ const results = new Array(items.length);
388
+ let nextIndex = 0;
389
+ async function worker() {
390
+ while (nextIndex < items.length) {
391
+ const index = nextIndex;
392
+ nextIndex += 1;
393
+ results[index] = await mapper(items[index], index);
394
+ }
395
+ }
396
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker));
397
+ return results;
398
+ }
399
+
400
+ function assertRange(range) {
401
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(range?.from || '')) ||
402
+ !/^\d{4}-\d{2}-\d{2}$/.test(String(range?.to || '')) ||
403
+ range.to <= range.from) {
404
+ throw new Error('Jira reconciliation requires a valid exclusive date range.');
405
+ }
406
+ }
407
+
408
+ function normalizeDateOnly(value) {
409
+ return String(value || '').match(/\d{4}-\d{2}-\d{2}/)?.[0] || '';
410
+ }
411
+
412
+ function normalizeIdentity(value) {
413
+ return String(value || '').trim().toLowerCase();
414
+ }
415
+
416
+ function compareEntries(left, right) {
417
+ return left.date.localeCompare(right.date) || String(left.issueKey || '').localeCompare(String(right.issueKey || ''));
418
+ }
419
+
420
+ function formatDayStatus(status) {
421
+ return ({
422
+ matched: 'Matched',
423
+ matched_incomplete: 'Matched, incomplete',
424
+ matched_unverified: 'Matched, unverified tasks',
425
+ missing_jira: 'Missing Jira',
426
+ missing_ro: 'Missing RO',
427
+ entry_mismatch: 'Task mismatch'
428
+ })[status] || status;
429
+ }
430
+
431
+ function formatPeriod(period) {
432
+ return String(period || '').replace(/_/g, ' ');
433
+ }
434
+
435
+ function formatSignedHours(value) {
436
+ const number = roundHours(value);
437
+ return `${number > 0 ? '+' : ''}${formatHours(number)}h`;
438
+ }
439
+
440
+ function formatHours(value) {
441
+ return Number(value || 0).toFixed(2).replace(/\.00$/, '').replace(/(\.\d)0$/, '$1');
442
+ }
443
+
444
+ function roundHours(value) {
445
+ return Number(Number(value || 0).toFixed(2));
446
+ }