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.
- package/LICENSE +11 -0
- package/README.md +330 -2
- package/RELEASE.md +89 -0
- package/bin/logwork.mjs +104 -0
- package/commands/auth.mjs +227 -0
- package/commands/commits.mjs +82 -0
- package/commands/delete.mjs +69 -0
- package/commands/doctor.mjs +24 -0
- package/commands/edit.mjs +88 -0
- package/commands/history.mjs +62 -0
- package/commands/query.mjs +52 -0
- package/commands/quick.mjs +163 -0
- package/commands/reconcile.mjs +55 -0
- package/commands/release.mjs +77 -0
- package/commands/reminder.mjs +172 -0
- package/commands/setup.mjs +508 -0
- package/commands/skill.mjs +91 -0
- package/commands/tui.mjs +50 -0
- package/commands/update.mjs +161 -0
- package/docs/advanced.md +334 -0
- package/docs/mcp-setup.md +368 -0
- package/docs/operations-guide.md +587 -0
- package/docs/recipes.md +259 -0
- package/docs/security.md +151 -0
- package/examples/mcp/antigravity.mcp_config.json +8 -0
- package/examples/mcp/codex.config.toml +5 -0
- package/examples/mcp/copilot.mcp.json +9 -0
- package/lib/agent-skill.mjs +16 -0
- package/lib/api-auth.mjs +1259 -0
- package/lib/api.mjs +1422 -0
- package/lib/apply-ledger.mjs +227 -0
- package/lib/atomic-file.mjs +24 -0
- package/lib/auth-diagnostics.mjs +113 -0
- package/lib/auth-errors.mjs +22 -0
- package/lib/auth-redaction.mjs +78 -0
- package/lib/auth-terminal.mjs +97 -0
- package/lib/auth.mjs +323 -0
- package/lib/batch-parser.mjs +145 -0
- package/lib/batch-workflow.mjs +842 -0
- package/lib/commit-workflow.mjs +402 -0
- package/lib/config.mjs +191 -0
- package/lib/credential-store.mjs +130 -0
- package/lib/diagnostics.mjs +307 -0
- package/lib/entrypoint.mjs +14 -0
- package/lib/file-lock.mjs +44 -0
- package/lib/http.mjs +125 -0
- package/lib/jira-api.mjs +258 -0
- package/lib/jira-auth.mjs +203 -0
- package/lib/jira-workflow.mjs +523 -0
- package/lib/keychain.mjs +105 -0
- package/lib/legacy-migration.mjs +66 -0
- package/lib/list-navigation.mjs +19 -0
- package/lib/logwork-config.mjs +199 -0
- package/lib/logwork-f03-enrich.mjs +41 -0
- package/lib/macos.mjs +33 -0
- package/lib/manual-apply-state.mjs +35 -0
- package/lib/manual-commands.mjs +294 -0
- package/lib/manual-drafts.mjs +154 -0
- package/lib/manual-ink-app.mjs +1654 -0
- package/lib/manual-ink-auth.mjs +349 -0
- package/lib/manual-ink-ui.mjs +675 -0
- package/lib/manual-input.mjs +21 -0
- package/lib/manual-logwork-wizard.mjs +310 -0
- package/lib/manual-mcp.mjs +187 -0
- package/lib/manual-repl.mjs +1037 -0
- package/lib/manual-targets.mjs +78 -0
- package/lib/mcp-server.mjs +833 -0
- package/lib/os-notification.mjs +105 -0
- package/lib/package-info.mjs +17 -0
- package/lib/paths.mjs +68 -0
- package/lib/project-identity.mjs +66 -0
- package/lib/project-mapping-workflow.mjs +309 -0
- package/lib/project-resolver.mjs +373 -0
- package/lib/query-workflow.mjs +553 -0
- package/lib/reconciliation-workflow.mjs +446 -0
- package/lib/release-doctor.mjs +448 -0
- package/lib/reminder-config.mjs +71 -0
- package/lib/reminder-scheduler.mjs +301 -0
- package/lib/reminder-service.mjs +118 -0
- package/lib/reminder-state.mjs +38 -0
- package/lib/reminder-workflow.mjs +299 -0
- package/lib/ro-delete-args.mjs +38 -0
- package/lib/ro-delete-workflow.mjs +115 -0
- package/lib/ro-edit-args.mjs +142 -0
- package/lib/ro-edit-workflow.mjs +632 -0
- package/lib/ro-entry-reader.mjs +56 -0
- package/lib/ro-resubmit-workflow.mjs +134 -0
- package/lib/terminal-auth-prompts.mjs +120 -0
- package/lib/ui.mjs +133 -0
- package/lib/update-service.mjs +350 -0
- package/lib/util.mjs +66 -0
- package/lib/windows-credential-manager.mjs +260 -0
- package/lib/worklog-task-resolver.mjs +113 -0
- package/package.json +73 -4
- package/setup.sh +22 -0
- 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
|
+
}
|