logwork-cli 1.1.0 → 1.2.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/README.md +35 -9
- package/RELEASE.md +1 -0
- package/commands/commits.mjs +57 -6
- package/commands/history.mjs +0 -4
- package/commands/quick.mjs +38 -23
- package/commands/reminder.mjs +41 -2
- package/commands/setup.mjs +157 -19
- package/commands/tui.mjs +1 -0
- package/docs/advanced.md +12 -2
- package/docs/mcp-setup.md +57 -15
- package/docs/operations-guide.md +37 -2
- package/docs/recipes.md +27 -0
- package/docs/security.md +3 -1
- package/examples/mcp/antigravity.mcp_config.json +1 -1
- package/examples/mcp/codex.config.toml +1 -1
- package/examples/mcp/copilot.mcp.json +1 -1
- package/lib/api-auth-forms.mjs +393 -0
- package/lib/api-auth-http.mjs +184 -0
- package/lib/api-auth-support.mjs +101 -0
- package/lib/api-auth-tokens.mjs +129 -0
- package/lib/api-auth.mjs +39 -813
- package/lib/api.mjs +38 -1422
- package/lib/apply-ledger.mjs +1 -1
- package/lib/batch-parser.mjs +23 -0
- package/lib/batch-workflow.mjs +201 -2
- package/lib/commit-workflow.mjs +278 -30
- package/lib/config.mjs +7 -0
- package/lib/diagnostics.mjs +53 -4
- package/lib/jira-api.mjs +25 -3
- package/lib/jira-workflow.mjs +36 -6
- package/lib/keychain.mjs +19 -12
- package/lib/logwork-config.mjs +64 -13
- package/lib/manual-apply-state.mjs +15 -0
- package/lib/manual-commands.mjs +2 -2
- package/lib/manual-ink-app.mjs +108 -1281
- package/lib/manual-ink-draft-actions.mjs +227 -0
- package/lib/manual-ink-interaction.mjs +306 -0
- package/lib/manual-ink-session.mjs +41 -0
- package/lib/manual-ink-task-editor.mjs +338 -0
- package/lib/manual-ink-wizard.mjs +520 -0
- package/lib/manual-mcp.mjs +27 -15
- package/lib/manual-repl.mjs +39 -3
- package/lib/mcp-server.mjs +63 -21
- package/lib/node-runtime.mjs +115 -0
- package/lib/npm-command.mjs +29 -0
- package/lib/paths.mjs +11 -1
- package/lib/project-mapping-workflow.mjs +30 -7
- package/lib/query-workflow.mjs +0 -1
- package/lib/reconciliation-workflow.mjs +1 -1
- package/lib/release-doctor.mjs +4 -2
- package/lib/reminder-config.mjs +38 -2
- package/lib/reminder-scheduler.mjs +54 -3
- package/lib/reminder-service.mjs +28 -3
- package/lib/reminder-workflow.mjs +118 -22
- package/lib/ro-api-diagnostics.mjs +109 -0
- package/lib/ro-api-entries.mjs +359 -0
- package/lib/ro-api-fields.mjs +290 -0
- package/lib/ro-api-http.mjs +68 -0
- package/lib/ro-api-projects.mjs +137 -0
- package/lib/ro-api-tasks.mjs +50 -0
- package/lib/ro-api-timesheet.mjs +413 -0
- package/lib/ro-delete-workflow.mjs +5 -8
- package/lib/ro-edit-workflow.mjs +66 -8
- package/lib/ro-entry-reader.mjs +49 -6
- package/lib/ro-resubmit-workflow.mjs +35 -21
- package/lib/terminal-auth-prompts.mjs +7 -4
- package/lib/ui.mjs +80 -59
- package/lib/update-service.mjs +5 -2
- package/package.json +11 -4
- package/skills/logwork/SKILL.md +6 -4
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Project memberships and per-project day logs from the legacy member-logtime API.
|
|
2
|
+
import { CONFIG } from './config.mjs';
|
|
3
|
+
import { mapLimit, toApiLogDate } from './util.mjs';
|
|
4
|
+
import { ApiError, apiFetch } from './ro-api-http.mjs';
|
|
5
|
+
import {
|
|
6
|
+
addDroppedRow,
|
|
7
|
+
attachNormalizationDiagnostics,
|
|
8
|
+
createNormalizationDiagnostics,
|
|
9
|
+
diagnosticProjectFields,
|
|
10
|
+
getNormalizationDiagnostics
|
|
11
|
+
} from './ro-api-diagnostics.mjs';
|
|
12
|
+
import {
|
|
13
|
+
hasProjectName,
|
|
14
|
+
normalizeDateOnly,
|
|
15
|
+
projectFallbackName,
|
|
16
|
+
readExplicitProjectMemberId,
|
|
17
|
+
readNumber,
|
|
18
|
+
readProjectId,
|
|
19
|
+
readProjectName,
|
|
20
|
+
unwrapRecords
|
|
21
|
+
} from './ro-api-fields.mjs';
|
|
22
|
+
|
|
23
|
+
export async function getProjects(token, userId) {
|
|
24
|
+
const qs = new URLSearchParams({
|
|
25
|
+
f_user_id: String(userId),
|
|
26
|
+
'f_project.stage_id': '!6'
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const data = await apiFetch(token, `${CONFIG.projectMembersPath}?${qs}`);
|
|
30
|
+
return unwrapRecords(data).map(normalizeProject).filter((project) => project.projectMemberId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function getDayLogs(token, projectId, userId, localDateISO) {
|
|
34
|
+
const qs = new URLSearchParams({
|
|
35
|
+
f_project_id: String(projectId),
|
|
36
|
+
f_user_id: String(userId),
|
|
37
|
+
f_logdate: toApiLogDate(localDateISO)
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const data = await apiFetch(token, `${CONFIG.memberLogtimePath}?${qs}`);
|
|
42
|
+
const logs = unwrapRecords(data);
|
|
43
|
+
const entries = normalizeLogtimeEntries(logs, {
|
|
44
|
+
date: localDateISO,
|
|
45
|
+
projectId
|
|
46
|
+
});
|
|
47
|
+
const normalization = getNormalizationDiagnostics(entries);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
projectId,
|
|
51
|
+
totalHours: entries.reduce((sum, log) => sum + Number(log.hours || 0), 0),
|
|
52
|
+
entries,
|
|
53
|
+
logs,
|
|
54
|
+
normalization,
|
|
55
|
+
ok: true
|
|
56
|
+
};
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error instanceof ApiError && error.status === 401) {
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
projectId,
|
|
64
|
+
totalHours: null,
|
|
65
|
+
logs: [],
|
|
66
|
+
ok: false,
|
|
67
|
+
error: error.message
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function normalizeLogtimeEntries(logs, defaults = {}) {
|
|
73
|
+
const rows = unwrapRecords(logs);
|
|
74
|
+
const diagnostics = createNormalizationDiagnostics('logtime_entries');
|
|
75
|
+
diagnostics.rowsRead = rows.length;
|
|
76
|
+
const entries = [];
|
|
77
|
+
|
|
78
|
+
rows.forEach((log, index) => {
|
|
79
|
+
if (!log || typeof log !== 'object') {
|
|
80
|
+
addDroppedRow(diagnostics, 'invalid_log_entry', { index });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const context = { index, kind: 'logtime_entry' };
|
|
85
|
+
const date = normalizeDateOnly(log.logdate ?? log.log_date ?? log.date) || defaults.date;
|
|
86
|
+
const projectMemberId = readExplicitProjectMemberId(log) ?? defaults.projectMemberId;
|
|
87
|
+
const projectId = readProjectId(log) ?? defaults.projectId;
|
|
88
|
+
const hours = readNumber(log, ['logtimes', 'hours', 'time'], diagnostics, context);
|
|
89
|
+
|
|
90
|
+
if (!date) {
|
|
91
|
+
addDroppedRow(diagnostics, 'missing_date', { ...context, ...diagnosticProjectFields({ projectMemberId, projectId }) });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (projectMemberId === undefined && projectId === undefined) {
|
|
96
|
+
addDroppedRow(diagnostics, 'missing_project_identity', { ...context, date });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (hours <= 0) {
|
|
101
|
+
addDroppedRow(diagnostics, 'non_positive_hours', { ...context, date, ...diagnosticProjectFields({ projectMemberId, projectId }) });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
diagnostics.rowsAccepted += 1;
|
|
106
|
+
entries.push({
|
|
107
|
+
id: log.id ?? log.logtime_id ?? log.logtimeId,
|
|
108
|
+
date,
|
|
109
|
+
projectMemberId,
|
|
110
|
+
projectId,
|
|
111
|
+
projectName: hasProjectName(log) ? readProjectName(log, readProjectId(log), diagnostics, context) : defaults.projectName,
|
|
112
|
+
hours,
|
|
113
|
+
taskName: log.task_name ?? log.taskName ?? log.name ?? log.title ?? ''
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return attachNormalizationDiagnostics(entries, diagnostics);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function getAllDayLogSummaries(token, projects, userId, localDateISO) {
|
|
121
|
+
return mapLimit(projects, CONFIG.dayLogConcurrency, (project) => (
|
|
122
|
+
getDayLogs(token, project.projectId, userId, localDateISO)
|
|
123
|
+
));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeProject(rec) {
|
|
127
|
+
const projectId = rec.project_id ?? rec.project?.id ?? rec.project?.project_id;
|
|
128
|
+
const projectMemberId = rec.id ?? rec.project_member_id;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
projectMemberId,
|
|
132
|
+
projectId,
|
|
133
|
+
projectName: rec.project?.name ?? rec.project_name ?? rec.name ?? projectFallbackName(projectId ?? projectMemberId),
|
|
134
|
+
workloadPercent: rec.workload ?? rec.workload_percent ?? rec.assigned_percent ?? 0,
|
|
135
|
+
raw: rec
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Worklog task catalog: project tasks plus shared default tasks.
|
|
2
|
+
import { CONFIG } from './config.mjs';
|
|
3
|
+
import { apiFetch } from './ro-api-http.mjs';
|
|
4
|
+
import { unwrapRecords } from './ro-api-fields.mjs';
|
|
5
|
+
import {
|
|
6
|
+
mergeWorklogTaskLists,
|
|
7
|
+
normalizeWorklogTask
|
|
8
|
+
} from './worklog-task-resolver.mjs';
|
|
9
|
+
|
|
10
|
+
export async function getWorklogTasks(token, { projectId, search } = {}, options = {}) {
|
|
11
|
+
const path = `${CONFIG.logworkTasksPath}${buildLogworkTasksQuery({ projectId, search })}`;
|
|
12
|
+
const data = await apiFetch(token, path, options);
|
|
13
|
+
return unwrapRecords(data).map(normalizeWorklogTask);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function getDefaultWorklogTasks(token, { projectId, search } = {}, options = {}) {
|
|
17
|
+
const path = `${CONFIG.logworkDefaultTasksPath}${buildLogworkTasksQuery({ projectId, search })}`;
|
|
18
|
+
const data = await apiFetch(token, path, options);
|
|
19
|
+
return unwrapRecords(data).map(normalizeWorklogTask);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function fetchWorklogTasksForProject(token, projectId, options = {}) {
|
|
23
|
+
const cacheKey = Number(projectId);
|
|
24
|
+
const { cache, ...apiOptions } = options;
|
|
25
|
+
if (cache instanceof Map && cache.has(cacheKey)) {
|
|
26
|
+
return cache.get(cacheKey);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const [projectTasks, defaultTasks] = await Promise.all([
|
|
30
|
+
getWorklogTasks(token, { projectId: cacheKey }, apiOptions),
|
|
31
|
+
getDefaultWorklogTasks(token, { projectId: cacheKey }, apiOptions)
|
|
32
|
+
]);
|
|
33
|
+
const merged = mergeWorklogTaskLists(projectTasks, defaultTasks);
|
|
34
|
+
if (cache instanceof Map) {
|
|
35
|
+
cache.set(cacheKey, merged);
|
|
36
|
+
}
|
|
37
|
+
return merged;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function buildLogworkTasksQuery({ projectId, search }) {
|
|
41
|
+
const qs = new URLSearchParams();
|
|
42
|
+
if (projectId !== undefined && projectId !== null && projectId !== '') {
|
|
43
|
+
qs.set('project_id', String(projectId));
|
|
44
|
+
}
|
|
45
|
+
if (search) {
|
|
46
|
+
qs.set('search', String(search));
|
|
47
|
+
}
|
|
48
|
+
const query = qs.toString();
|
|
49
|
+
return query ? `?${query}` : '';
|
|
50
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// Timesheet range reads: booked and logged hours per day and project.
|
|
2
|
+
import { CONFIG } from './config.mjs';
|
|
3
|
+
import { addLocalDays, toApiLogDate } from './util.mjs';
|
|
4
|
+
import { apiFetch } from './ro-api-http.mjs';
|
|
5
|
+
import {
|
|
6
|
+
addDroppedRow,
|
|
7
|
+
addWarning,
|
|
8
|
+
attachNormalizationDiagnostics,
|
|
9
|
+
createNormalizationDiagnostics,
|
|
10
|
+
diagnosticProjectFields,
|
|
11
|
+
getNormalizationDiagnostics
|
|
12
|
+
} from './ro-api-diagnostics.mjs';
|
|
13
|
+
import {
|
|
14
|
+
coerceHours,
|
|
15
|
+
hasProjectName,
|
|
16
|
+
isDateInRange,
|
|
17
|
+
normalizeDateOnly,
|
|
18
|
+
pickFirst,
|
|
19
|
+
readAssignedHours,
|
|
20
|
+
readDateLike,
|
|
21
|
+
readLoggedHours,
|
|
22
|
+
readNumber,
|
|
23
|
+
readProjectId,
|
|
24
|
+
readProjectMemberId,
|
|
25
|
+
readProjectName
|
|
26
|
+
} from './ro-api-fields.mjs';
|
|
27
|
+
|
|
28
|
+
export async function getTimesheetRange(token, userId, { from, to }) {
|
|
29
|
+
const { records } = await getTimesheetRangeResult(token, userId, { from, to });
|
|
30
|
+
return records;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function getTimesheetRangeResult(token, userId, { from, to }) {
|
|
34
|
+
const qs = new URLSearchParams({
|
|
35
|
+
f_user_id: String(userId),
|
|
36
|
+
f_from: toApiLogDate(from),
|
|
37
|
+
f_to: toApiLogDate(to),
|
|
38
|
+
f_timesheet_by_week: '0',
|
|
39
|
+
f_time_off: '1'
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const data = await apiFetch(token, `${CONFIG.timesheetPath}?${qs}`);
|
|
43
|
+
return normalizeTimesheetRangeResult(data, { from, to });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function normalizeTimesheetRange(data, { from, to } = {}) {
|
|
47
|
+
return normalizeTimesheetRangeResult(data, { from, to }).records;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function normalizeTimesheetRangeResult(data, { from, to } = {}) {
|
|
51
|
+
const diagnostics = createNormalizationDiagnostics('unknown');
|
|
52
|
+
const explicitRows = expandProjectTimesheetRows(data, { from, to }, diagnostics);
|
|
53
|
+
const rows = explicitRows.length ? explicitRows : findTimesheetRowsInRange(data, { from, to });
|
|
54
|
+
diagnostics.sourceShape = explicitRows.length ? 'project_timesheet' : 'range_scan';
|
|
55
|
+
if (!rows.length && hasObjectPayload(data)) {
|
|
56
|
+
addWarning(diagnostics, 'unknown_timesheet_shape');
|
|
57
|
+
}
|
|
58
|
+
const records = normalizeTimesheetRecords(rows, diagnostics);
|
|
59
|
+
return {
|
|
60
|
+
records,
|
|
61
|
+
normalization: getNormalizationDiagnostics(records)
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function getBookedProjectsForDate(token, userId, localDateISO) {
|
|
66
|
+
const { records } = await getTimesheetRangeResult(token, userId, {
|
|
67
|
+
from: localDateISO,
|
|
68
|
+
to: addLocalDays(localDateISO, 1)
|
|
69
|
+
});
|
|
70
|
+
return normalizeTimesheetProjects(records, localDateISO);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function getTodayTimesheetProjects(token, userId, localDateISO) {
|
|
74
|
+
return getBookedProjectsForDate(token, userId, localDateISO);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function normalizeTimesheetProjects(rows, localDateISO) {
|
|
78
|
+
const byMember = new Map();
|
|
79
|
+
|
|
80
|
+
for (const row of rows) {
|
|
81
|
+
const projectMemberId = readProjectMemberId(row);
|
|
82
|
+
const projectId = readProjectId(row);
|
|
83
|
+
|
|
84
|
+
if (!projectMemberId && !projectId) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const key = String(projectMemberId ?? projectId);
|
|
89
|
+
const projectName = readProjectName(row, projectId ?? projectMemberId);
|
|
90
|
+
const assignedHours = Number(row.bookedHours ?? readAssignedHours(row));
|
|
91
|
+
const loggedHours = Number(row.loggedHours ?? readLoggedHours(row));
|
|
92
|
+
const existing = byMember.get(key);
|
|
93
|
+
|
|
94
|
+
if (existing) {
|
|
95
|
+
existing.assignedHours += assignedHours;
|
|
96
|
+
existing.loggedHours += loggedHours;
|
|
97
|
+
existing.workloadPercent = hoursToAssignPercent(existing.assignedHours);
|
|
98
|
+
existing.raw.push(row);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
byMember.set(key, {
|
|
103
|
+
projectMemberId: projectMemberId ?? row.project_member_id ?? row.id,
|
|
104
|
+
projectId,
|
|
105
|
+
projectName,
|
|
106
|
+
assignedHours,
|
|
107
|
+
loggedHours,
|
|
108
|
+
workloadPercent: hoursToAssignPercent(assignedHours),
|
|
109
|
+
localDateISO,
|
|
110
|
+
raw: [row]
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return [...byMember.values()].filter((project) => project.assignedHours > 0);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizeTimesheetRecords(rows, diagnostics = createNormalizationDiagnostics('range_scan')) {
|
|
118
|
+
const byDayProject = new Map();
|
|
119
|
+
diagnostics.rowsRead = rows.length;
|
|
120
|
+
|
|
121
|
+
rows.forEach((row, index) => {
|
|
122
|
+
const date = readDateLike(row);
|
|
123
|
+
const projectMemberId = readProjectMemberId(row);
|
|
124
|
+
const projectId = readProjectId(row);
|
|
125
|
+
const context = {
|
|
126
|
+
index,
|
|
127
|
+
kind: 'timesheet_row',
|
|
128
|
+
...diagnosticProjectFields({ projectMemberId, projectId, projectName: hasProjectName(row) ? readProjectName(row, projectId ?? projectMemberId) : undefined })
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
if (!date) {
|
|
132
|
+
addDroppedRow(diagnostics, 'missing_date', context);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (projectMemberId === undefined && projectId === undefined) {
|
|
137
|
+
addDroppedRow(diagnostics, 'missing_project_identity', { ...context, date });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const key = `${date}:${projectMemberId ?? projectId}`;
|
|
142
|
+
const existing = byDayProject.get(key);
|
|
143
|
+
const bookedHours = readTimesheetHours(row, ['bookedHours'], readAssignedHours, diagnostics, { ...context, date, metric: 'bookedHours' });
|
|
144
|
+
const loggedHours = readTimesheetHours(row, ['loggedHours'], readLoggedHours, diagnostics, { ...context, date, metric: 'loggedHours' });
|
|
145
|
+
const entries = readLogEntries(row, diagnostics, { ...context, date });
|
|
146
|
+
diagnostics.rowsAccepted += 1;
|
|
147
|
+
|
|
148
|
+
if (existing) {
|
|
149
|
+
existing.bookedHours += bookedHours;
|
|
150
|
+
existing.loggedHours += loggedHours;
|
|
151
|
+
existing.entries.push(...entries);
|
|
152
|
+
existing.raw.push(row);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
byDayProject.set(key, {
|
|
157
|
+
date,
|
|
158
|
+
projectMemberId,
|
|
159
|
+
projectId,
|
|
160
|
+
projectName: readProjectName(row, projectId ?? projectMemberId, diagnostics, { ...context, date }),
|
|
161
|
+
bookedHours,
|
|
162
|
+
loggedHours,
|
|
163
|
+
entries,
|
|
164
|
+
raw: [row]
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return attachNormalizationDiagnostics([...byDayProject.values()].map((record) => ({
|
|
169
|
+
...record,
|
|
170
|
+
bookedHours: Number(record.bookedHours.toFixed(2)),
|
|
171
|
+
loggedHours: Number(record.loggedHours.toFixed(2))
|
|
172
|
+
})), diagnostics);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function expandProjectTimesheetRows(data, range, diagnostics = null) {
|
|
176
|
+
const assignments = [];
|
|
177
|
+
collectProjectAssignments(data, assignments, new Set());
|
|
178
|
+
const rows = [];
|
|
179
|
+
|
|
180
|
+
assignments.forEach((assignment, assignmentIndex) => {
|
|
181
|
+
assignment.timesheet.forEach((day, dayIndex) => {
|
|
182
|
+
const date = normalizeDateOnly(day.logdate ?? day.log_date ?? day.date);
|
|
183
|
+
if (!isDateInRange(date, range)) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const context = {
|
|
188
|
+
index: rows.length,
|
|
189
|
+
assignmentIndex,
|
|
190
|
+
dayIndex,
|
|
191
|
+
kind: 'project_timesheet_row',
|
|
192
|
+
date,
|
|
193
|
+
...diagnosticProjectFields({
|
|
194
|
+
projectMemberId: assignment.id,
|
|
195
|
+
projectId: assignment.project?.id,
|
|
196
|
+
projectName: assignment.project?.name
|
|
197
|
+
})
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
rows.push({
|
|
201
|
+
date,
|
|
202
|
+
projectMemberId: assignment.id,
|
|
203
|
+
project_member_id: assignment.id,
|
|
204
|
+
projectId: assignment.project?.id,
|
|
205
|
+
project_id: assignment.project?.id,
|
|
206
|
+
projectName: assignment.project?.name,
|
|
207
|
+
project_name: assignment.project?.name,
|
|
208
|
+
bookedHours: readAssignedHours(day, diagnostics, { ...context, metric: 'bookedHours' }),
|
|
209
|
+
loggedHours: readNumber(day, ['logtimes'], diagnostics, { ...context, metric: 'loggedHours' }),
|
|
210
|
+
overtime: readNumber(day, ['overtime'], diagnostics, { ...context, metric: 'overtime' }),
|
|
211
|
+
source: 'timesheet'
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return rows;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function collectProjectAssignments(value, assignments, seen) {
|
|
220
|
+
if (!value || typeof value !== 'object') {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (seen.has(value)) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
seen.add(value);
|
|
228
|
+
|
|
229
|
+
if (Array.isArray(value)) {
|
|
230
|
+
for (const item of value) {
|
|
231
|
+
collectProjectAssignments(item, assignments, seen);
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (
|
|
237
|
+
value.project &&
|
|
238
|
+
typeof value.project === 'object' &&
|
|
239
|
+
Array.isArray(value.timesheet)
|
|
240
|
+
) {
|
|
241
|
+
assignments.push(value);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for (const [key, child] of Object.entries(value)) {
|
|
246
|
+
if (key === 'overall') {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
collectProjectAssignments(child, assignments, seen);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function findTimesheetRowsInRange(data, { from, to } = {}) {
|
|
254
|
+
const rows = [];
|
|
255
|
+
collectTimesheetRowsInRange(data, { from, to }, rows, new Set());
|
|
256
|
+
return rows;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function collectTimesheetRowsInRange(value, range, rows, seen) {
|
|
260
|
+
if (!value || typeof value !== 'object') {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (seen.has(value)) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
seen.add(value);
|
|
268
|
+
|
|
269
|
+
if (Array.isArray(value)) {
|
|
270
|
+
for (const item of value) {
|
|
271
|
+
collectTimesheetRowsInRange(item, range, rows, seen);
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const directDate = readDateLike(value);
|
|
277
|
+
if (isDateInRange(directDate, range) && looksLikeTimesheetRow(value)) {
|
|
278
|
+
rows.push(value);
|
|
279
|
+
} else if (!directDate && looksLikeTimesheetRow(value)) {
|
|
280
|
+
rows.push(value);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
for (const [key, child] of Object.entries(value)) {
|
|
284
|
+
if (Array.isArray(child)) {
|
|
285
|
+
const keyDate = normalizeDateOnly(key);
|
|
286
|
+
if (isDateInRange(keyDate, range)) {
|
|
287
|
+
for (const item of child) {
|
|
288
|
+
if (item && typeof item === 'object') {
|
|
289
|
+
rows.push({ ...item, date: readDateLike(item) || keyDate });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for (const item of child) {
|
|
296
|
+
if (item && typeof item === 'object') {
|
|
297
|
+
const itemDate = readDateLike(item);
|
|
298
|
+
if (isDateInRange(itemDate, range) && looksLikeTimesheetRow(item)) {
|
|
299
|
+
rows.push(item);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
collectTimesheetRowsInRange(item, range, rows, seen);
|
|
305
|
+
}
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (child && typeof child === 'object') {
|
|
310
|
+
const keyDate = normalizeDateOnly(key);
|
|
311
|
+
if (isDateInRange(keyDate, range)) {
|
|
312
|
+
rows.push({ ...child, date: readDateLike(child) || keyDate });
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
collectTimesheetRowsInRange(child, range, rows, seen);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function looksLikeTimesheetRow(row) {
|
|
322
|
+
return readAssignedHours(row) > 0 || readLoggedHours(row) > 0 || Boolean(pickFirst(row, [
|
|
323
|
+
'project_member_id',
|
|
324
|
+
'projectMemberId',
|
|
325
|
+
'project_id',
|
|
326
|
+
'projectId'
|
|
327
|
+
]));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function readLogEntries(row, diagnostics = null, context = {}) {
|
|
331
|
+
const rawEntries = pickFirst(row, [
|
|
332
|
+
'logs',
|
|
333
|
+
'logtimes_data',
|
|
334
|
+
'logtimesData',
|
|
335
|
+
'log_times',
|
|
336
|
+
'logTimes',
|
|
337
|
+
'items',
|
|
338
|
+
'children'
|
|
339
|
+
]);
|
|
340
|
+
|
|
341
|
+
if (!Array.isArray(rawEntries)) {
|
|
342
|
+
return [];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const entries = [];
|
|
346
|
+
rawEntries.forEach((entry, entryIndex) => {
|
|
347
|
+
if (!entry || typeof entry !== 'object') {
|
|
348
|
+
addDroppedRow(diagnostics, 'invalid_log_entry', {
|
|
349
|
+
...context,
|
|
350
|
+
entryIndex
|
|
351
|
+
});
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const entryContext = {
|
|
356
|
+
...context,
|
|
357
|
+
entryIndex,
|
|
358
|
+
kind: 'embedded_log_entry'
|
|
359
|
+
};
|
|
360
|
+
const date = normalizeDateOnly(entry.logdate ?? entry.log_date ?? entry.date ?? row.logdate ?? row.date);
|
|
361
|
+
const projectMemberId = readProjectMemberId(entry) ?? readProjectMemberId(row);
|
|
362
|
+
const projectId = readProjectId(entry) ?? readProjectId(row);
|
|
363
|
+
const hours = readNumber(entry, ['logtimes', 'hours', 'time'], diagnostics, { ...entryContext, metric: 'hours' });
|
|
364
|
+
|
|
365
|
+
if (!date) {
|
|
366
|
+
addDroppedRow(diagnostics, 'missing_date', { ...entryContext, ...diagnosticProjectFields({ projectMemberId, projectId }) });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (projectMemberId === undefined && projectId === undefined) {
|
|
371
|
+
addDroppedRow(diagnostics, 'missing_project_identity', { ...entryContext, date });
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (hours <= 0) {
|
|
376
|
+
addDroppedRow(diagnostics, 'non_positive_hours', { ...entryContext, date, ...diagnosticProjectFields({ projectMemberId, projectId }) });
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
entries.push({
|
|
381
|
+
id: entry.id ?? entry.logtime_id ?? entry.logtimeId,
|
|
382
|
+
date,
|
|
383
|
+
projectMemberId,
|
|
384
|
+
projectId,
|
|
385
|
+
projectName: hasProjectName(entry) ? readProjectName(entry, readProjectId(entry), diagnostics, entryContext) : readProjectName(row, readProjectId(row) ?? readProjectMemberId(row), diagnostics, entryContext),
|
|
386
|
+
hours,
|
|
387
|
+
taskName: entry.task_name ?? entry.taskName ?? entry.name ?? entry.title ?? '',
|
|
388
|
+
raw: entry
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
return entries;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function readTimesheetHours(row, directPaths, fallbackReader, diagnostics, context) {
|
|
396
|
+
const directValue = pickFirst(row, directPaths);
|
|
397
|
+
if (directValue !== undefined) {
|
|
398
|
+
return coerceHours(directValue, diagnostics, {
|
|
399
|
+
...context,
|
|
400
|
+
field: directPaths[0]
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return fallbackReader(row, diagnostics, context);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function hoursToAssignPercent(hours) {
|
|
408
|
+
return Math.round((Number(hours || 0) / CONFIG.standardDayHours) * 100);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function hasObjectPayload(data) {
|
|
412
|
+
return Boolean(data && typeof data === 'object' && Object.keys(data).length > 0);
|
|
413
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { ApiError
|
|
1
|
+
import { ApiError } from './ro-api-http.mjs';
|
|
2
|
+
import { deleteLogworkEntry } from './ro-api-entries.mjs';
|
|
2
3
|
import { createResourceOptimiserSession } from './query-workflow.mjs';
|
|
3
|
-
import { createF03LogworkReader } from './ro-entry-reader.mjs';
|
|
4
|
+
import { assertOwnUnlockedRoEntry, createF03LogworkReader, createRoEntryRevisionFingerprint } from './ro-entry-reader.mjs';
|
|
4
5
|
|
|
5
6
|
const DELETABLE_STATUSES = new Set(['submitted', 'approved']);
|
|
6
7
|
|
|
@@ -11,6 +12,7 @@ export async function previewRoLogworkDelete({
|
|
|
11
12
|
const reader = fetchLogwork || createF03LogworkReader();
|
|
12
13
|
const current = await reader(assertIdentifier(logworkId, 'logworkId'));
|
|
13
14
|
assertDeletableStatus(current);
|
|
15
|
+
assertOwnUnlockedRoEntry(current, 'delete');
|
|
14
16
|
|
|
15
17
|
const preview = {
|
|
16
18
|
previewId: `delete_${current.id}`,
|
|
@@ -106,10 +108,5 @@ function assertIdentifier(value, label) {
|
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
function createLogworkRevisionFingerprint(logwork) {
|
|
109
|
-
return
|
|
110
|
-
id: logwork.id,
|
|
111
|
-
hours: logwork.hours,
|
|
112
|
-
status: logwork.status,
|
|
113
|
-
taskName: logwork.taskName
|
|
114
|
-
});
|
|
111
|
+
return createRoEntryRevisionFingerprint(logwork);
|
|
115
112
|
}
|