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,553 @@
1
+ import { CONFIG } from './config.mjs';
2
+ import { getFreshToken, getUserIdFromJwt } from './auth.mjs';
3
+ import {
4
+ ApiError,
5
+ combineNormalizationDiagnostics,
6
+ getDayLogs,
7
+ getLogworkDay,
8
+ getLogworkRejected,
9
+ getNormalizationDiagnostics,
10
+ getTimesheetRangeResult
11
+ } from './api.mjs';
12
+ import { enrichEntriesWithLogworkF03 } from './logwork-f03-enrich.mjs';
13
+ import { loadLocalConfig } from './logwork-config.mjs';
14
+ import {
15
+ normalizeProjectName,
16
+ projectIdentityKey,
17
+ projectMatchesFilter,
18
+ projectMatchesMapping,
19
+ sameProjectIdentity
20
+ } from './project-identity.mjs';
21
+ import { addLocalDays, todayLocalDateISO } from './util.mjs';
22
+
23
+ export async function queryLogwork({
24
+ date,
25
+ from,
26
+ to,
27
+ period,
28
+ project,
29
+ includeEntries = true,
30
+ cwd = process.cwd(),
31
+ fetchRange,
32
+ fetchDayLogs,
33
+ fetchLogworkDay,
34
+ includeRejected = false
35
+ } = {}) {
36
+ const range = normalizeQueryRange({ date, from, to, period });
37
+ const config = await loadLocalConfig(cwd);
38
+ const rangeFetcher = fetchRange || createTimesheetRangeFetcher();
39
+ const dayLogsFetcher = fetchDayLogs || createDayLogsFetcher();
40
+ const rangeResult = normalizeRangeResult(await rangeFetcher(range));
41
+ const records = rangeResult.records;
42
+ const filterResult = filterRecordsByProject(records, project, config);
43
+ const filteredRecords = filterResult.records;
44
+ const detail = includeEntries
45
+ ? await collectEntries(filteredRecords, dayLogsFetcher)
46
+ : { entries: [], missingDetailEntries: [] };
47
+
48
+ const f03DayFetcher = fetchLogworkDay || createLogworkDayFetcher();
49
+ const uniqueDates = [...new Set(filteredRecords.map((record) => record.date).filter(Boolean))];
50
+ const f03Enrichment = await enrichEntriesWithLogworkF03(detail.entries, {
51
+ dates: uniqueDates,
52
+ fetchLogworkDay: async (date) => f03DayFetcher(date)
53
+ });
54
+
55
+ const rejectedEntries = includeRejected
56
+ ? await createLogworkRejectedFetcher()()
57
+ : [];
58
+
59
+ const normalization = combineNormalizationDiagnostics(rangeResult.normalization, detail.normalization);
60
+ const totals = buildTotals(filteredRecords);
61
+
62
+ const payload = {
63
+ range,
64
+ totals,
65
+ days: buildDays(filteredRecords, detail.entries),
66
+ projects: buildProjects(filteredRecords),
67
+ entries: f03Enrichment.entries,
68
+ f03ByDate: f03Enrichment.f03ByDate,
69
+ rejectedEntries,
70
+ missingDetailEntries: detail.missingDetailEntries,
71
+ normalization,
72
+ unmatchedProjectFilter: filterResult.unmatchedProjectFilter,
73
+ summary: formatQuerySummary(
74
+ filteredRecords,
75
+ detail.entries,
76
+ range,
77
+ filterResult.unmatchedProjectFilter,
78
+ detail.missingDetailEntries,
79
+ normalization,
80
+ totals
81
+ )
82
+ };
83
+
84
+ return payload;
85
+ }
86
+
87
+ export function normalizeQueryRange({ date, from, to, period } = {}) {
88
+ if (date) {
89
+ return {
90
+ from: assertDateOnly(date, 'date'),
91
+ to: addLocalDays(assertDateOnly(date, 'date'), 1)
92
+ };
93
+ }
94
+
95
+ if (from || to) {
96
+ const start = assertDateOnly(from || todayLocalDateISO(), 'from');
97
+ const end = assertDateOnly(to || addLocalDays(start, 1), 'to');
98
+ if (end <= start) {
99
+ throw new Error('to must be after from.');
100
+ }
101
+
102
+ return { from: start, to: end };
103
+ }
104
+
105
+ if (period === 'this_week') {
106
+ return currentWeekRange();
107
+ }
108
+
109
+ if (period === 'last_week') {
110
+ return lastWeekRange();
111
+ }
112
+
113
+ if (period === 'this_month') {
114
+ return currentMonthRange();
115
+ }
116
+
117
+ if (period === 'last_month') {
118
+ return lastMonthRange();
119
+ }
120
+
121
+ const today = todayLocalDateISO();
122
+ if (period === 'yesterday') {
123
+ return {
124
+ from: addLocalDays(today, -1),
125
+ to: today
126
+ };
127
+ }
128
+
129
+ return {
130
+ from: today,
131
+ to: addLocalDays(today, 1)
132
+ };
133
+ }
134
+
135
+ export function filterRecordsByProject(records, projectFilter, config = {}) {
136
+ if (projectFilter === undefined || projectFilter === null || String(projectFilter).trim() === '') {
137
+ return {
138
+ records,
139
+ unmatchedProjectFilter: null
140
+ };
141
+ }
142
+
143
+ const filter = String(projectFilter).trim();
144
+ const matches = records.filter((record) => recordMatchesFilter(record, filter, config));
145
+ const candidates = uniqueProjects(records);
146
+
147
+ if (!matches.length) {
148
+ return {
149
+ records: [],
150
+ unmatchedProjectFilter: {
151
+ filter,
152
+ candidates
153
+ }
154
+ };
155
+ }
156
+
157
+ return {
158
+ records: matches,
159
+ unmatchedProjectFilter: null
160
+ };
161
+ }
162
+
163
+ export function buildTotals(records) {
164
+ return records.reduce((totals, record) => ({
165
+ bookedHours: roundHours(totals.bookedHours + Number(record.bookedHours || 0)),
166
+ loggedHours: roundHours(totals.loggedHours + Number(record.loggedHours || 0)),
167
+ projectCount: new Set([...totals.projectKeys, projectIdentityKey(record)]).size,
168
+ dayCount: new Set([...totals.days, record.date]).size,
169
+ projectKeys: [...new Set([...totals.projectKeys, projectIdentityKey(record)])],
170
+ days: [...new Set([...totals.days, record.date])]
171
+ }), {
172
+ bookedHours: 0,
173
+ loggedHours: 0,
174
+ projectCount: 0,
175
+ dayCount: 0,
176
+ projectKeys: [],
177
+ days: []
178
+ });
179
+ }
180
+
181
+ export async function collectEntries(records, fetchDayLogs) {
182
+ const entries = [];
183
+ const missingDetailEntries = [];
184
+ const normalizationItems = [];
185
+
186
+ for (const record of records) {
187
+ if (!record.loggedHours || !record.projectId) {
188
+ continue;
189
+ }
190
+
191
+ const dayLogs = await fetchDayLogs({
192
+ date: record.date,
193
+ projectId: record.projectId,
194
+ projectMemberId: record.projectMemberId,
195
+ projectName: record.projectName
196
+ });
197
+ normalizationItems.push(dayLogs.normalization);
198
+
199
+ const detailEntries = dayLogs.entries?.length
200
+ ? dayLogs.entries
201
+ : (dayLogs.logs || []).map((log) => sanitizeEntry(log, record));
202
+
203
+ if (!detailEntries.length) {
204
+ missingDetailEntries.push({
205
+ date: record.date,
206
+ projectMemberId: record.projectMemberId,
207
+ projectId: record.projectId,
208
+ projectName: record.projectName,
209
+ loggedHours: record.loggedHours
210
+ });
211
+ continue;
212
+ }
213
+
214
+ for (const entry of detailEntries) {
215
+ entries.push(sanitizeEntry(entry, record));
216
+ }
217
+ }
218
+
219
+ return {
220
+ entries,
221
+ missingDetailEntries,
222
+ normalization: combineNormalizationDiagnostics(...normalizationItems)
223
+ };
224
+ }
225
+
226
+ export function createTimesheetRangeFetcher() {
227
+ const session = createResourceOptimiserSession();
228
+ return async (range) => {
229
+ const { token, userId } = await session.get();
230
+ try {
231
+ return await getTimesheetRangeResult(token, userId, range);
232
+ } catch (error) {
233
+ if (!(error instanceof ApiError) || error.status !== 401) {
234
+ throw error;
235
+ }
236
+
237
+ const refreshed = await session.get({ forceLogin: true });
238
+ return getTimesheetRangeResult(refreshed.token, refreshed.userId, range);
239
+ }
240
+ };
241
+ }
242
+
243
+ export function createLogworkDayFetcher() {
244
+ const session = createResourceOptimiserSession();
245
+ return async (date) => {
246
+ const { token } = await session.get();
247
+ try {
248
+ return await getLogworkDay(token, { date });
249
+ } catch (error) {
250
+ if (!(error instanceof ApiError) || error.status !== 401) {
251
+ throw error;
252
+ }
253
+ const refreshed = await session.get({ forceLogin: true });
254
+ return getLogworkDay(refreshed.token, { date });
255
+ }
256
+ };
257
+ }
258
+
259
+ export function createLogworkRejectedFetcher() {
260
+ const session = createResourceOptimiserSession();
261
+ return async () => {
262
+ const { token } = await session.get();
263
+ try {
264
+ return await getLogworkRejected(token);
265
+ } catch (error) {
266
+ if (!(error instanceof ApiError) || error.status !== 401) {
267
+ throw error;
268
+ }
269
+ const refreshed = await session.get({ forceLogin: true });
270
+ return getLogworkRejected(refreshed.token);
271
+ }
272
+ };
273
+ }
274
+
275
+ export function createDayLogsFetcher() {
276
+ const session = createResourceOptimiserSession();
277
+ return async ({ date, projectId }) => {
278
+ const { token, userId } = await session.get();
279
+ try {
280
+ return await getDayLogs(token, projectId, userId, date);
281
+ } catch (error) {
282
+ if (!(error instanceof ApiError) || error.status !== 401) {
283
+ throw error;
284
+ }
285
+
286
+ const refreshed = await session.get({ forceLogin: true });
287
+ return getDayLogs(refreshed.token, projectId, refreshed.userId, date);
288
+ }
289
+ };
290
+ }
291
+
292
+ export function createResourceOptimiserSession() {
293
+ let cached = null;
294
+
295
+ return {
296
+ async get({ forceLogin = false } = {}) {
297
+ if (cached && !forceLogin) {
298
+ return cached;
299
+ }
300
+
301
+ const token = await getFreshToken({
302
+ interactive: false,
303
+ forceLogin
304
+ });
305
+ cached = {
306
+ token,
307
+ userId: getUserIdFromJwt(token)
308
+ };
309
+ return cached;
310
+ }
311
+ };
312
+ }
313
+
314
+ function buildDays(records, entries) {
315
+ const byDate = new Map();
316
+ for (const record of records) {
317
+ const existing = byDate.get(record.date) || {
318
+ date: record.date,
319
+ bookedHours: 0,
320
+ loggedHours: 0,
321
+ projects: []
322
+ };
323
+ existing.bookedHours = roundHours(existing.bookedHours + Number(record.bookedHours || 0));
324
+ existing.loggedHours = roundHours(existing.loggedHours + Number(record.loggedHours || 0));
325
+ existing.projects.push({
326
+ ...projectSummary(record),
327
+ entries: entries.filter((entry) => (
328
+ entry.date === record.date &&
329
+ sameProjectIdentity(entry, record)
330
+ ))
331
+ });
332
+ byDate.set(record.date, existing);
333
+ }
334
+
335
+ for (const day of byDate.values()) {
336
+ day.entries = entries.filter((entry) => entry.date === day.date);
337
+ }
338
+
339
+ return [...byDate.values()].sort((left, right) => left.date.localeCompare(right.date));
340
+ }
341
+
342
+ function buildProjects(records) {
343
+ const byProject = new Map();
344
+ for (const record of records) {
345
+ const key = projectIdentityKey(record);
346
+ const existing = byProject.get(key) || {
347
+ projectMemberId: record.projectMemberId,
348
+ projectId: record.projectId,
349
+ projectName: record.projectName,
350
+ bookedHours: 0,
351
+ loggedHours: 0,
352
+ dates: []
353
+ };
354
+ existing.bookedHours = roundHours(existing.bookedHours + Number(record.bookedHours || 0));
355
+ existing.loggedHours = roundHours(existing.loggedHours + Number(record.loggedHours || 0));
356
+ existing.dates = [...new Set([...existing.dates, record.date])].sort();
357
+ byProject.set(key, existing);
358
+ }
359
+
360
+ return [...byProject.values()].sort((left, right) => left.projectName.localeCompare(right.projectName));
361
+ }
362
+
363
+ function recordMatchesFilter(record, filter, config) {
364
+ if (projectMatchesFilter(record, filter)) {
365
+ return true;
366
+ }
367
+
368
+ return (config.projectMappings || []).some((mapping) => {
369
+ if (!projectMatchesMapping(record, mapping, { fallbackToName: true })) {
370
+ return false;
371
+ }
372
+
373
+ const normalizedFilter = normalizeProjectName(filter);
374
+ return (mapping.tickets || []).some((ticket) => normalizeProjectName(ticket) === normalizedFilter) ||
375
+ (mapping.keywords || []).some((keyword) => {
376
+ const normalizedKeyword = normalizeProjectName(keyword);
377
+ return normalizedKeyword &&
378
+ (normalizedKeyword.includes(normalizedFilter) || normalizedFilter.includes(normalizedKeyword));
379
+ });
380
+ });
381
+ }
382
+
383
+ function uniqueProjects(records) {
384
+ return buildProjects(records).map((project) => ({
385
+ projectMemberId: project.projectMemberId,
386
+ projectId: project.projectId,
387
+ projectName: project.projectName
388
+ }));
389
+ }
390
+
391
+ function sanitizeEntry(entry, record) {
392
+ const hours = Number(entry.hours ?? entry.logtimes ?? entry.time ?? 0);
393
+ return {
394
+ id: entry.id ?? entry.logtime_id ?? entry.logtimeId,
395
+ date: entry.date || normalizeDateOnly(entry.logdate ?? entry.log_date) || record.date,
396
+ projectMemberId: entry.projectMemberId ?? entry.project_member_id ?? record.projectMemberId,
397
+ projectId: entry.projectId ?? entry.project_id ?? record.projectId,
398
+ projectName: entry.projectName ?? entry.project_name ?? record.projectName,
399
+ hours: Number.isFinite(hours) && hours > 0 ? hours : 0,
400
+ taskName: entry.taskName ?? entry.task_name ?? entry.name ?? entry.title ?? ''
401
+ };
402
+ }
403
+
404
+ function projectSummary(record) {
405
+ return {
406
+ projectMemberId: record.projectMemberId,
407
+ projectId: record.projectId,
408
+ projectName: record.projectName,
409
+ bookedHours: record.bookedHours,
410
+ loggedHours: record.loggedHours
411
+ };
412
+ }
413
+
414
+ function assertDateOnly(value, name) {
415
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
416
+ throw new Error(`${name} must use YYYY-MM-DD format.`);
417
+ }
418
+
419
+ return value;
420
+ }
421
+
422
+ function currentWeekRange(date = new Date()) {
423
+ const start = new Date(date.getFullYear(), date.getMonth(), date.getDate());
424
+ const day = start.getDay();
425
+ const diffToMonday = day === 0 ? -6 : 1 - day;
426
+ start.setDate(start.getDate() + diffToMonday);
427
+ const end = new Date(start);
428
+ end.setDate(start.getDate() + 7);
429
+
430
+ return {
431
+ from: formatLocalDate(start),
432
+ to: formatLocalDate(end)
433
+ };
434
+ }
435
+
436
+ function lastWeekRange(date = new Date()) {
437
+ const current = currentWeekRange(date);
438
+ return {
439
+ from: addLocalDays(current.from, -7),
440
+ to: current.from
441
+ };
442
+ }
443
+
444
+ function currentMonthRange(date = new Date()) {
445
+ const start = new Date(date.getFullYear(), date.getMonth(), 1);
446
+ const end = new Date(date.getFullYear(), date.getMonth() + 1, 1);
447
+
448
+ return {
449
+ from: formatLocalDate(start),
450
+ to: formatLocalDate(end)
451
+ };
452
+ }
453
+
454
+ function lastMonthRange(date = new Date()) {
455
+ const start = new Date(date.getFullYear(), date.getMonth() - 1, 1);
456
+ const end = new Date(date.getFullYear(), date.getMonth(), 1);
457
+
458
+ return {
459
+ from: formatLocalDate(start),
460
+ to: formatLocalDate(end)
461
+ };
462
+ }
463
+
464
+ function formatLocalDate(date) {
465
+ return [
466
+ String(date.getFullYear()).padStart(4, '0'),
467
+ String(date.getMonth() + 1).padStart(2, '0'),
468
+ String(date.getDate()).padStart(2, '0')
469
+ ].join('-');
470
+ }
471
+
472
+ function normalizeDateOnly(value) {
473
+ if (!value) {
474
+ return '';
475
+ }
476
+
477
+ return String(value).match(/\d{4}-\d{2}-\d{2}/)?.[0] || '';
478
+ }
479
+
480
+ function roundHours(value) {
481
+ return Number(Number(value || 0).toFixed(2));
482
+ }
483
+
484
+ function normalizeRangeResult(result) {
485
+ if (Array.isArray(result)) {
486
+ return {
487
+ records: result,
488
+ normalization: getNormalizationDiagnostics(result)
489
+ };
490
+ }
491
+
492
+ if (result && Array.isArray(result.records)) {
493
+ return {
494
+ records: result.records,
495
+ normalization: result.normalization || getNormalizationDiagnostics(result.records)
496
+ };
497
+ }
498
+
499
+ return {
500
+ records: [],
501
+ normalization: getNormalizationDiagnostics(result)
502
+ };
503
+ }
504
+
505
+ function formatQuerySummary(
506
+ records,
507
+ entries,
508
+ range,
509
+ unmatchedProjectFilter,
510
+ missingDetailEntries = [],
511
+ normalization = null,
512
+ totals = buildTotals(records)
513
+ ) {
514
+ if (unmatchedProjectFilter) {
515
+ return `No logwork projects matched "${unmatchedProjectFilter.filter}" from ${range.from} to ${range.to}.`;
516
+ }
517
+
518
+ const lines = [
519
+ `Logwork from ${range.from} to ${range.to}: ${totals.loggedHours}h logged / ${totals.bookedHours}h booked.`
520
+ ];
521
+
522
+ if (totals.bookedHours === 0 && totals.loggedHours > 0) {
523
+ lines.push('Booked hours are 0 but logged hours exist; check timesheet normalization warnings before assuming no booking.');
524
+ const warningReasons = [...new Set((normalization?.warnings || []).map((warning) => warning.reason).filter(Boolean))];
525
+ if (warningReasons.length) {
526
+ lines.push(`Timesheet warnings: ${warningReasons.join(', ')}.`);
527
+ }
528
+ }
529
+
530
+ for (const day of buildDays(records, entries)) {
531
+ lines.push(day.date);
532
+ for (const project of day.projects) {
533
+ lines.push(`- ${project.projectName}: ${project.loggedHours}h logged / ${project.bookedHours}h booked`);
534
+ for (const entry of project.entries) {
535
+ const id = entry.id === undefined || entry.id === null || entry.id === ''
536
+ ? ''
537
+ : ` [logworkId: ${entry.id}]`;
538
+ const statusSuffix = entry.status ? ` (${entry.status})` : '';
539
+ const descriptionSuffix = entry.description ? ` | ${entry.description}` : '';
540
+ lines.push(` -${id} +${entry.hours}h ${entry.taskName || '(no task name)'}${descriptionSuffix}${statusSuffix}`);
541
+ }
542
+ }
543
+ }
544
+
545
+ if (missingDetailEntries.length) {
546
+ lines.push('Missing detail entries:');
547
+ for (const missing of missingDetailEntries) {
548
+ lines.push(`- ${missing.date}: ${missing.projectName} has ${missing.loggedHours}h logged but no task detail returned.`);
549
+ }
550
+ }
551
+
552
+ return lines.join('\n');
553
+ }