@bakers-build/addon-track 0.0.0-stage → 0.1.0-next.42
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 +21 -0
- package/README.md +4 -2
- package/dist/src/dates.d.ts +7 -0
- package/dist/src/dates.js +55 -0
- package/dist/src/evidence.d.ts +104 -0
- package/dist/src/evidence.js +418 -0
- package/dist/src/index.d.ts +12 -0
- package/dist/src/index.js +38 -0
- package/dist/src/preset.d.ts +2 -0
- package/dist/src/preset.js +8 -0
- package/package.json +27 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 keytec GmbH
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @bakers-build/addon-track
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Read personal daily evidence for time preparation.
|
|
4
|
+
|
|
5
|
+
Part of the bakers.build CLI. Install the meta package `@bakers-build/bakers` to get the `bakers` CLI with all addons. Source: https://git.key-tec.de/bakers-build/bakers-build (bakers-cli/).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function civilDate(value: string): number;
|
|
2
|
+
export declare function shiftDate(date: string, days: number): string;
|
|
3
|
+
export declare function selectedDates(from: string, to: string): string[];
|
|
4
|
+
export declare function calendar(timezone: string): {
|
|
5
|
+
dateAt: (time: number) => string;
|
|
6
|
+
boundary: (date: string) => number;
|
|
7
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const DAY = 86_400_000;
|
|
2
|
+
export function civilDate(value) {
|
|
3
|
+
const time = Date.parse(`${value}T00:00:00Z`);
|
|
4
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) ||
|
|
5
|
+
!Number.isFinite(time) ||
|
|
6
|
+
new Date(time).toISOString().slice(0, 10) !== value) {
|
|
7
|
+
throw new Error(`Invalid civil date: ${value}`);
|
|
8
|
+
}
|
|
9
|
+
return time;
|
|
10
|
+
}
|
|
11
|
+
export function shiftDate(date, days) {
|
|
12
|
+
return new Date(civilDate(date) + days * DAY).toISOString().slice(0, 10);
|
|
13
|
+
}
|
|
14
|
+
export function selectedDates(from, to) {
|
|
15
|
+
const count = (civilDate(to) - civilDate(from)) / DAY + 1;
|
|
16
|
+
if (count < 1 || count > 366)
|
|
17
|
+
throw new Error('Select between 1 and 366 civil dates.');
|
|
18
|
+
return Array.from({ length: count }, (_, i) => shiftDate(from, i));
|
|
19
|
+
}
|
|
20
|
+
export function calendar(timezone) {
|
|
21
|
+
const formatter = new Intl.DateTimeFormat('en-CA', {
|
|
22
|
+
timeZone: timezone,
|
|
23
|
+
year: 'numeric',
|
|
24
|
+
month: '2-digit',
|
|
25
|
+
day: '2-digit',
|
|
26
|
+
});
|
|
27
|
+
const dateAt = (time) => {
|
|
28
|
+
const parts = formatter.formatToParts(time);
|
|
29
|
+
return ['year', 'month', 'day']
|
|
30
|
+
.map((type) => parts.find((part) => part.type === type)?.value)
|
|
31
|
+
.join('-');
|
|
32
|
+
};
|
|
33
|
+
const cache = new Map();
|
|
34
|
+
const boundary = (date) => {
|
|
35
|
+
const cached = cache.get(date);
|
|
36
|
+
if (cached !== undefined)
|
|
37
|
+
return cached;
|
|
38
|
+
// Find the first local instant, including zones whose clocks skip midnight.
|
|
39
|
+
const utc = civilDate(date);
|
|
40
|
+
let low = utc - 2 * DAY;
|
|
41
|
+
let high = utc + 2 * DAY;
|
|
42
|
+
while (high - low > 1) {
|
|
43
|
+
const middle = Math.floor((low + high) / 2);
|
|
44
|
+
if (dateAt(middle) < date)
|
|
45
|
+
low = middle;
|
|
46
|
+
else
|
|
47
|
+
high = middle;
|
|
48
|
+
}
|
|
49
|
+
if (dateAt(high) !== date)
|
|
50
|
+
throw new Error(`Civil date ${date} does not exist in ${timezone}.`);
|
|
51
|
+
cache.set(date, high);
|
|
52
|
+
return high;
|
|
53
|
+
};
|
|
54
|
+
return { dateAt, boundary };
|
|
55
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { createBbConnectionClient } from '@bakers-build/core';
|
|
2
|
+
type Client = Awaited<ReturnType<typeof createBbConnectionClient>>;
|
|
3
|
+
type Selection = {
|
|
4
|
+
from: string;
|
|
5
|
+
to: string;
|
|
6
|
+
timezone?: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function loadEvidence(client: Client, selection: Selection, worklogClient: Pick<Client, 'get'>): Promise<{
|
|
9
|
+
owner_id: string;
|
|
10
|
+
timezone: string;
|
|
11
|
+
from: string;
|
|
12
|
+
to: string;
|
|
13
|
+
complete: boolean;
|
|
14
|
+
evidence_gaps: string[];
|
|
15
|
+
unplaced_run_ids: string[];
|
|
16
|
+
days: {
|
|
17
|
+
recorded_seconds: number | null;
|
|
18
|
+
proposed_seconds: number | null;
|
|
19
|
+
baseline: {
|
|
20
|
+
date: string;
|
|
21
|
+
run_count: number | null;
|
|
22
|
+
known_duration_seconds: number;
|
|
23
|
+
first_start: string | null;
|
|
24
|
+
last_end: string | null;
|
|
25
|
+
span_complete: boolean;
|
|
26
|
+
}[];
|
|
27
|
+
baseline_median: number | null;
|
|
28
|
+
baseline_samples: number;
|
|
29
|
+
schedule_seconds: number | null;
|
|
30
|
+
cap_seconds: number | null;
|
|
31
|
+
activities: {
|
|
32
|
+
id: string;
|
|
33
|
+
date: string | null;
|
|
34
|
+
created: string | null;
|
|
35
|
+
kind: unknown;
|
|
36
|
+
actor_id: string | null;
|
|
37
|
+
run_id: string | null;
|
|
38
|
+
ticket_id: string | null;
|
|
39
|
+
previous_state: {} | null;
|
|
40
|
+
new_state: {} | null;
|
|
41
|
+
step: {} | null;
|
|
42
|
+
comment_id: string | null;
|
|
43
|
+
summary: string | null;
|
|
44
|
+
summary_truncated: boolean;
|
|
45
|
+
}[];
|
|
46
|
+
worklog_ids: string[];
|
|
47
|
+
date: string;
|
|
48
|
+
start: string;
|
|
49
|
+
end: string;
|
|
50
|
+
run_count: number | null;
|
|
51
|
+
observed_run_count: number;
|
|
52
|
+
span_complete: boolean;
|
|
53
|
+
states: Record<string, number>;
|
|
54
|
+
first_start: string | null;
|
|
55
|
+
last_end: string | null;
|
|
56
|
+
known_duration_seconds: number;
|
|
57
|
+
clusters: {
|
|
58
|
+
start: string;
|
|
59
|
+
end: string;
|
|
60
|
+
}[];
|
|
61
|
+
gaps: {
|
|
62
|
+
start: string;
|
|
63
|
+
end: string;
|
|
64
|
+
seconds: number;
|
|
65
|
+
}[];
|
|
66
|
+
runs: {
|
|
67
|
+
start: string;
|
|
68
|
+
end: string | null;
|
|
69
|
+
full_duration_seconds: number | null;
|
|
70
|
+
id: string;
|
|
71
|
+
state: {} | null;
|
|
72
|
+
step: {} | null;
|
|
73
|
+
ticket_id: string | null;
|
|
74
|
+
conductor_id: string | null;
|
|
75
|
+
}[];
|
|
76
|
+
}[];
|
|
77
|
+
weeks: {
|
|
78
|
+
recorded_seconds: number | null;
|
|
79
|
+
proposed_seconds: number | null;
|
|
80
|
+
from: string;
|
|
81
|
+
to: string;
|
|
82
|
+
schedule_seconds: number | null;
|
|
83
|
+
}[];
|
|
84
|
+
worklogs: {
|
|
85
|
+
id: string;
|
|
86
|
+
date: unknown;
|
|
87
|
+
state: unknown;
|
|
88
|
+
duration_seconds: number | null;
|
|
89
|
+
description: unknown;
|
|
90
|
+
ticket_id: string | null;
|
|
91
|
+
project_id: string | null;
|
|
92
|
+
submission_id: string | null;
|
|
93
|
+
source_keys: {} | null;
|
|
94
|
+
}[];
|
|
95
|
+
tickets: Record<string, {
|
|
96
|
+
identifier: unknown;
|
|
97
|
+
title: unknown;
|
|
98
|
+
project_id: string | null;
|
|
99
|
+
}>;
|
|
100
|
+
projects: Record<string, {
|
|
101
|
+
name: unknown;
|
|
102
|
+
}>;
|
|
103
|
+
}>;
|
|
104
|
+
export {};
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
|
|
2
|
+
import { calendar, civilDate, selectedDates, shiftDate } from './dates.js';
|
|
3
|
+
const attr = (row, key) => row.attributes?.[key];
|
|
4
|
+
const relation = (row, key) => {
|
|
5
|
+
const value = row.relationships?.[key]?.data;
|
|
6
|
+
return value && !Array.isArray(value) && typeof value.id === 'string'
|
|
7
|
+
? value.id
|
|
8
|
+
: null;
|
|
9
|
+
};
|
|
10
|
+
const stamp = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value))
|
|
11
|
+
? Date.parse(value)
|
|
12
|
+
: null;
|
|
13
|
+
const iso = (time) => new Date(time).toISOString();
|
|
14
|
+
function rows(value) {
|
|
15
|
+
return (Array.isArray(value) &&
|
|
16
|
+
value.every((row) => row &&
|
|
17
|
+
typeof row === 'object' &&
|
|
18
|
+
typeof row.id === 'string' &&
|
|
19
|
+
typeof row.type === 'string'));
|
|
20
|
+
}
|
|
21
|
+
async function collect(client, type, params, path = `${type}/${type}`) {
|
|
22
|
+
const data = new Map();
|
|
23
|
+
const included = new Map();
|
|
24
|
+
let complete = true;
|
|
25
|
+
let offset = 0;
|
|
26
|
+
while (true) {
|
|
27
|
+
params.addPageLimit(50).addPageOffset(offset);
|
|
28
|
+
const raw = await client.get(path, params);
|
|
29
|
+
if (!raw || typeof raw !== 'object' || !('data' in raw) || !rows(raw.data))
|
|
30
|
+
throw new Error(`Invalid ${type} collection response.`);
|
|
31
|
+
const page = raw;
|
|
32
|
+
if (page.included !== undefined && !rows(page.included))
|
|
33
|
+
throw new Error(`Invalid ${type} included resources.`);
|
|
34
|
+
if (page.meta?.omitted)
|
|
35
|
+
complete = false;
|
|
36
|
+
for (const row of page.data) {
|
|
37
|
+
if (row.type !== `${type}--${type}`)
|
|
38
|
+
throw new Error(`Unexpected resource in ${type}.`);
|
|
39
|
+
data.set(row.id, row);
|
|
40
|
+
}
|
|
41
|
+
for (const row of page.included ?? [])
|
|
42
|
+
included.set(`${row.type}/${row.id}`, row);
|
|
43
|
+
if (!page.links?.next)
|
|
44
|
+
break;
|
|
45
|
+
const href = typeof page.links.next === 'string'
|
|
46
|
+
? page.links.next
|
|
47
|
+
: page.links.next.href;
|
|
48
|
+
if (!href)
|
|
49
|
+
throw new Error(`Invalid ${type} pagination link.`);
|
|
50
|
+
// Consume only the next offset; keep our authenticated endpoint and filters.
|
|
51
|
+
const next = new URL(href, 'https://pagination.invalid').searchParams.get('page[offset]');
|
|
52
|
+
if (next === null ||
|
|
53
|
+
!/^\d+$/.test(next) ||
|
|
54
|
+
!Number.isSafeInteger(Number(next)) ||
|
|
55
|
+
Number(next) <= offset)
|
|
56
|
+
throw new Error(`Non-advancing ${type} pagination.`);
|
|
57
|
+
offset = Number(next);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
data: [...data.values()],
|
|
61
|
+
included: [...included.values()],
|
|
62
|
+
complete,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function context(params) {
|
|
66
|
+
return params
|
|
67
|
+
.addInclude(['ticket_id.project_id'])
|
|
68
|
+
.addFields('bb_ticket--bb_ticket', ['identifier', 'title', 'project_id'])
|
|
69
|
+
.addFields('bb_project--bb_project', ['name']);
|
|
70
|
+
}
|
|
71
|
+
export async function loadEvidence(client, selection, worklogClient) {
|
|
72
|
+
const dates = selectedDates(selection.from, selection.to);
|
|
73
|
+
if (selection.timezone)
|
|
74
|
+
calendar(selection.timezone);
|
|
75
|
+
const owner = await client.me();
|
|
76
|
+
const user = await client.resource('user', owner);
|
|
77
|
+
const timezone = selection.timezone ?? user.attr('timezone');
|
|
78
|
+
if (!timezone)
|
|
79
|
+
throw new Error('Employee timezone is unavailable; supply --timezone.');
|
|
80
|
+
const { boundary, dateAt } = calendar(timezone);
|
|
81
|
+
const start = boundary(selection.from);
|
|
82
|
+
const end = boundary(shiftDate(selection.to, 1));
|
|
83
|
+
const historyStart = boundary(shiftDate(selection.from, -28));
|
|
84
|
+
const weekday = (date) => (new Date(civilDate(date)).getUTCDay() + 6) % 7;
|
|
85
|
+
const weekStart = shiftDate(selection.from, -weekday(selection.from));
|
|
86
|
+
const weekEnd = shiftDate(selection.to, 6 - weekday(selection.to));
|
|
87
|
+
const rawSchedule = user.attr('work_schedule');
|
|
88
|
+
const schedule = (Array.isArray(rawSchedule) ? rawSchedule[0] : rawSchedule);
|
|
89
|
+
const seconds = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
|
|
90
|
+
? value
|
|
91
|
+
: null;
|
|
92
|
+
const evidenceGaps = [];
|
|
93
|
+
const runParams = context(new DrupalJsonApiParams())
|
|
94
|
+
.addFilter('owner_user_id.id', owner)
|
|
95
|
+
.addGroup('start', 'OR')
|
|
96
|
+
.addFilter('started_at', String(end / 1000), '<', 'start')
|
|
97
|
+
.addGroup('not_started', 'AND', 'start')
|
|
98
|
+
.addFilter('started_at', null, 'IS NULL', 'not_started')
|
|
99
|
+
.addFilter('created', String(end / 1000), '<', 'not_started')
|
|
100
|
+
.addGroup('end', 'OR')
|
|
101
|
+
.addFilter('closed_date', String(historyStart / 1000), '>=', 'end')
|
|
102
|
+
.addFilter('closed_date', null, 'IS NULL', 'end')
|
|
103
|
+
.addFields('bb_run--bb_run', [
|
|
104
|
+
'owner_user_id',
|
|
105
|
+
'started_at',
|
|
106
|
+
'closed_date',
|
|
107
|
+
'state',
|
|
108
|
+
'state_at_start',
|
|
109
|
+
'ticket_id',
|
|
110
|
+
'conductor_id',
|
|
111
|
+
])
|
|
112
|
+
.addSort('drupal_internal__id');
|
|
113
|
+
const activityParams = context(new DrupalJsonApiParams())
|
|
114
|
+
.addFilter('created', String(start / 1000), '>=')
|
|
115
|
+
.addFilter('created', String(end / 1000), '<')
|
|
116
|
+
.addGroup('personal', 'OR')
|
|
117
|
+
.addFilter('actor.id', owner, '=', 'personal')
|
|
118
|
+
.addFilter('run_id.owner_user_id.id', owner, '=', 'personal')
|
|
119
|
+
.addInclude(['ticket_id.project_id', 'comment_id', 'run_id'])
|
|
120
|
+
.addFields('bb_run--bb_run', ['owner_user_id'])
|
|
121
|
+
.addFields('bb_activity--bb_activity', [
|
|
122
|
+
'created',
|
|
123
|
+
'kind',
|
|
124
|
+
'actor',
|
|
125
|
+
'run_id',
|
|
126
|
+
'ticket_id',
|
|
127
|
+
'comment_id',
|
|
128
|
+
'previous_state',
|
|
129
|
+
'new_state',
|
|
130
|
+
'step',
|
|
131
|
+
])
|
|
132
|
+
.addFields('bb_comment--bb_comment', ['body'])
|
|
133
|
+
.addSort('drupal_internal__id');
|
|
134
|
+
const worklogParams = new DrupalJsonApiParams().addCustomParam({
|
|
135
|
+
from: weekStart,
|
|
136
|
+
to: weekEnd,
|
|
137
|
+
});
|
|
138
|
+
const [runPage, activityPage, worklogPage] = await Promise.all([
|
|
139
|
+
collect(client, 'bb_run', runParams),
|
|
140
|
+
collect(client, 'bb_activity', activityParams),
|
|
141
|
+
collect(worklogClient, 'bb_track_worklog', worklogParams, 'worklogs'),
|
|
142
|
+
]);
|
|
143
|
+
for (const [name, page] of [
|
|
144
|
+
['runs', runPage],
|
|
145
|
+
['activities', activityPage],
|
|
146
|
+
['worklogs', worklogPage],
|
|
147
|
+
]) {
|
|
148
|
+
if (!page.complete)
|
|
149
|
+
evidenceGaps.push(`Some ${name} or included resources were omitted by the server.`);
|
|
150
|
+
}
|
|
151
|
+
const tickets = {};
|
|
152
|
+
const projects = {};
|
|
153
|
+
const summaries = new Map();
|
|
154
|
+
for (const row of [
|
|
155
|
+
...runPage.included,
|
|
156
|
+
...activityPage.included,
|
|
157
|
+
...worklogPage.included,
|
|
158
|
+
]) {
|
|
159
|
+
if (row.type === 'bb_ticket--bb_ticket')
|
|
160
|
+
tickets[row.id] = {
|
|
161
|
+
identifier: attr(row, 'identifier') ?? null,
|
|
162
|
+
title: attr(row, 'title') ?? null,
|
|
163
|
+
project_id: relation(row, 'project_id'),
|
|
164
|
+
};
|
|
165
|
+
if (row.type === 'bb_project--bb_project')
|
|
166
|
+
projects[row.id] = { name: attr(row, 'name') ?? null };
|
|
167
|
+
if (row.type === 'bb_comment--bb_comment') {
|
|
168
|
+
const body = attr(row, 'body');
|
|
169
|
+
if (typeof body?.value === 'string')
|
|
170
|
+
summaries.set(row.id, body.value);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const unplaced = [];
|
|
174
|
+
for (const [id, ticket] of Object.entries(tickets))
|
|
175
|
+
if (ticket.project_id && !projects[ticket.project_id])
|
|
176
|
+
evidenceGaps.push(`Ticket ${id} has unavailable project context.`);
|
|
177
|
+
const runs = runPage.data
|
|
178
|
+
.filter((row) => relation(row, 'owner_user_id') === owner)
|
|
179
|
+
.map((row) => {
|
|
180
|
+
const start = stamp(attr(row, 'started_at'));
|
|
181
|
+
const rawEnd = attr(row, 'closed_date');
|
|
182
|
+
let end = stamp(rawEnd);
|
|
183
|
+
if (start === null)
|
|
184
|
+
unplaced.push(row.id);
|
|
185
|
+
if ((rawEnd !== null && rawEnd !== undefined && end === null) ||
|
|
186
|
+
(start !== null && end !== null && end < start)) {
|
|
187
|
+
evidenceGaps.push(`Run ${row.id} has an invalid end.`);
|
|
188
|
+
end = null;
|
|
189
|
+
}
|
|
190
|
+
if (end === null)
|
|
191
|
+
evidenceGaps.push(`Run ${row.id} has an unknown end; later-day overlap cannot be confirmed.`);
|
|
192
|
+
const ticketId = relation(row, 'ticket_id');
|
|
193
|
+
if (!ticketId || !tickets[ticketId])
|
|
194
|
+
evidenceGaps.push(`Run ${row.id} has unavailable ticket context.`);
|
|
195
|
+
return {
|
|
196
|
+
id: row.id,
|
|
197
|
+
start,
|
|
198
|
+
end,
|
|
199
|
+
state: attr(row, 'state') ?? null,
|
|
200
|
+
step: attr(row, 'state_at_start') ?? null,
|
|
201
|
+
ticket_id: ticketId,
|
|
202
|
+
conductor_id: relation(row, 'conductor_id'),
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
if (unplaced.length)
|
|
206
|
+
evidenceGaps.push(`Runs with unknown start: ${unplaced.join(', ')}.`);
|
|
207
|
+
for (const row of runPage.data)
|
|
208
|
+
if (!relation(row, 'owner_user_id'))
|
|
209
|
+
evidenceGaps.push(`Run ${row.id} has unknown ownership.`);
|
|
210
|
+
const runsComplete = runPage.complete &&
|
|
211
|
+
unplaced.length === 0 &&
|
|
212
|
+
runPage.data.every((row) => relation(row, 'owner_user_id'));
|
|
213
|
+
const knownRuns = new Set([
|
|
214
|
+
...runs.map((run) => run.id),
|
|
215
|
+
...activityPage.included
|
|
216
|
+
.filter((row) => row.type === 'bb_run--bb_run' &&
|
|
217
|
+
relation(row, 'owner_user_id') === owner)
|
|
218
|
+
.map((row) => row.id),
|
|
219
|
+
]);
|
|
220
|
+
for (const row of activityPage.data) {
|
|
221
|
+
if (relation(row, 'actor') !== owner &&
|
|
222
|
+
!knownRuns.has(relation(row, 'run_id') ?? ''))
|
|
223
|
+
evidenceGaps.push(`Activity ${row.id} could not be attributed to this employee.`);
|
|
224
|
+
}
|
|
225
|
+
const activities = activityPage.data
|
|
226
|
+
.filter((row) => relation(row, 'actor') === owner ||
|
|
227
|
+
knownRuns.has(relation(row, 'run_id') ?? ''))
|
|
228
|
+
.map((row) => {
|
|
229
|
+
const time = stamp(attr(row, 'created'));
|
|
230
|
+
const commentId = relation(row, 'comment_id');
|
|
231
|
+
const summary = commentId ? summaries.get(commentId) : undefined;
|
|
232
|
+
if (time === null)
|
|
233
|
+
evidenceGaps.push(`Activity ${row.id} has an unknown timestamp.`);
|
|
234
|
+
if (commentId && summary === undefined)
|
|
235
|
+
evidenceGaps.push(`Activity ${row.id} has an unavailable summary.`);
|
|
236
|
+
return {
|
|
237
|
+
id: row.id,
|
|
238
|
+
date: time === null ? null : dateAt(time),
|
|
239
|
+
created: time === null ? null : iso(time),
|
|
240
|
+
kind: attr(row, 'kind'),
|
|
241
|
+
actor_id: relation(row, 'actor'),
|
|
242
|
+
run_id: relation(row, 'run_id'),
|
|
243
|
+
ticket_id: relation(row, 'ticket_id'),
|
|
244
|
+
previous_state: attr(row, 'previous_state') ?? null,
|
|
245
|
+
new_state: attr(row, 'new_state') ?? null,
|
|
246
|
+
step: attr(row, 'step') ?? null,
|
|
247
|
+
comment_id: commentId,
|
|
248
|
+
summary: summary?.slice(0, 2000) ?? null,
|
|
249
|
+
summary_truncated: (summary?.length ?? 0) > 2000,
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
const worklogs = worklogPage.data
|
|
253
|
+
.filter((row) => relation(row, 'owner_user_id') === owner)
|
|
254
|
+
.map((row) => ({
|
|
255
|
+
id: row.id,
|
|
256
|
+
date: attr(row, 'work_date'),
|
|
257
|
+
state: attr(row, 'state'),
|
|
258
|
+
duration_seconds: seconds(attr(row, 'duration_seconds')),
|
|
259
|
+
description: attr(row, 'description'),
|
|
260
|
+
ticket_id: relation(row, 'ticket_id'),
|
|
261
|
+
project_id: relation(row, 'project_id'),
|
|
262
|
+
submission_id: relation(row, 'submission_id'),
|
|
263
|
+
source_keys: attr(row, 'source_keys') ?? null,
|
|
264
|
+
}));
|
|
265
|
+
const worklogsValid = worklogPage.data.every((row) => relation(row, 'owner_user_id')) &&
|
|
266
|
+
worklogs.every((row) => typeof row.date === 'string' &&
|
|
267
|
+
Number.isFinite(civilDate(row.date)) &&
|
|
268
|
+
row.duration_seconds !== null &&
|
|
269
|
+
['recorded', 'approved', 'proposed'].includes(String(row.state)));
|
|
270
|
+
if (!worklogsValid)
|
|
271
|
+
evidenceGaps.push('Some worklogs have invalid dates, states or durations.');
|
|
272
|
+
for (const row of [...activities, ...worklogs]) {
|
|
273
|
+
if (row.ticket_id && !tickets[row.ticket_id])
|
|
274
|
+
evidenceGaps.push(`Resource ${row.id} has unavailable ticket context.`);
|
|
275
|
+
}
|
|
276
|
+
for (const row of worklogs)
|
|
277
|
+
if (row.project_id && !projects[row.project_id])
|
|
278
|
+
evidenceGaps.push(`Worklog ${row.id} has unavailable project context.`);
|
|
279
|
+
const totals = (items) => ({
|
|
280
|
+
recorded_seconds: worklogPage.complete && worklogsValid
|
|
281
|
+
? items
|
|
282
|
+
.filter((w) => w.state === 'recorded' || w.state === 'approved')
|
|
283
|
+
.reduce((sum, w) => sum + (w.duration_seconds ?? 0), 0)
|
|
284
|
+
: null,
|
|
285
|
+
proposed_seconds: worklogPage.complete && worklogsValid
|
|
286
|
+
? items
|
|
287
|
+
.filter((w) => w.state === 'proposed')
|
|
288
|
+
.reduce((sum, w) => sum + (w.duration_seconds ?? 0), 0)
|
|
289
|
+
: null,
|
|
290
|
+
});
|
|
291
|
+
function daily(date) {
|
|
292
|
+
const low = boundary(date), high = boundary(shiftDate(date, 1));
|
|
293
|
+
const selected = runs.filter((run) => run.start !== null &&
|
|
294
|
+
run.start < high &&
|
|
295
|
+
(run.end === null ||
|
|
296
|
+
run.end > low ||
|
|
297
|
+
(run.end === run.start && run.start >= low)));
|
|
298
|
+
const uncertainOverlap = selected.some((run) => run.end === null && run.start < low);
|
|
299
|
+
const intervals = selected
|
|
300
|
+
.filter((r) => r.end !== null)
|
|
301
|
+
.map((r) => ({
|
|
302
|
+
start: Math.max(r.start, low),
|
|
303
|
+
end: Math.min(r.end, high),
|
|
304
|
+
}))
|
|
305
|
+
.sort((a, b) => a.start - b.start);
|
|
306
|
+
const merged = [];
|
|
307
|
+
for (const interval of intervals) {
|
|
308
|
+
const last = merged.at(-1);
|
|
309
|
+
if (last && interval.start <= last.end)
|
|
310
|
+
last.end = Math.max(last.end, interval.end);
|
|
311
|
+
else
|
|
312
|
+
merged.push({ ...interval });
|
|
313
|
+
}
|
|
314
|
+
const spanComplete = runsComplete && selected.every((run) => run.end !== null);
|
|
315
|
+
const clusters = merged.map((r) => ({
|
|
316
|
+
start: iso(r.start),
|
|
317
|
+
end: iso(r.end),
|
|
318
|
+
}));
|
|
319
|
+
const gaps = [];
|
|
320
|
+
if (spanComplete) {
|
|
321
|
+
for (let i = 1; i < merged.length; i++) {
|
|
322
|
+
const previous = merged[i - 1], current = merged[i];
|
|
323
|
+
if (previous && current)
|
|
324
|
+
gaps.push({
|
|
325
|
+
start: iso(previous.end),
|
|
326
|
+
end: iso(current.start),
|
|
327
|
+
seconds: (current.start - previous.end) / 1000,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const last = merged.at(-1);
|
|
332
|
+
const states = {};
|
|
333
|
+
for (const run of selected)
|
|
334
|
+
states[String(run.state)] = (states[String(run.state)] ?? 0) + 1;
|
|
335
|
+
return {
|
|
336
|
+
date,
|
|
337
|
+
start: iso(low),
|
|
338
|
+
end: iso(high),
|
|
339
|
+
run_count: runsComplete && !uncertainOverlap ? selected.length : null,
|
|
340
|
+
observed_run_count: selected.filter((r) => r.end !== null || r.start >= low).length,
|
|
341
|
+
span_complete: spanComplete,
|
|
342
|
+
states,
|
|
343
|
+
first_start: selected.length
|
|
344
|
+
? iso(Math.max(low, Math.min(...selected.map((r) => r.start))))
|
|
345
|
+
: null,
|
|
346
|
+
last_end: last ? iso(last.end) : null,
|
|
347
|
+
known_duration_seconds: merged.reduce((sum, r) => sum + (r.end - r.start) / 1000, 0),
|
|
348
|
+
clusters,
|
|
349
|
+
gaps,
|
|
350
|
+
runs: selected.map((r) => ({
|
|
351
|
+
...r,
|
|
352
|
+
start: iso(r.start),
|
|
353
|
+
end: r.end === null ? null : iso(r.end),
|
|
354
|
+
full_duration_seconds: r.end === null ? null : (r.end - r.start) / 1000,
|
|
355
|
+
})),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
const days = dates.map((date) => {
|
|
359
|
+
const day = daily(date);
|
|
360
|
+
const baseline = [7, 14, 21, 28].map((delta) => {
|
|
361
|
+
const previous = daily(shiftDate(date, -delta));
|
|
362
|
+
return {
|
|
363
|
+
date: previous.date,
|
|
364
|
+
run_count: previous.run_count,
|
|
365
|
+
known_duration_seconds: previous.known_duration_seconds,
|
|
366
|
+
first_start: previous.first_start,
|
|
367
|
+
last_end: previous.last_end,
|
|
368
|
+
span_complete: previous.span_complete,
|
|
369
|
+
};
|
|
370
|
+
});
|
|
371
|
+
const counts = baseline
|
|
372
|
+
.map((b) => b.run_count)
|
|
373
|
+
.filter((n) => n !== null)
|
|
374
|
+
.sort((a, b) => a - b);
|
|
375
|
+
const middle = counts.slice(Math.floor((counts.length - 1) / 2), Math.floor(counts.length / 2) + 1);
|
|
376
|
+
const median = middle.length
|
|
377
|
+
? middle.reduce((sum, value) => sum + value, 0) / middle.length
|
|
378
|
+
: null;
|
|
379
|
+
return {
|
|
380
|
+
...day,
|
|
381
|
+
baseline,
|
|
382
|
+
baseline_median: median,
|
|
383
|
+
baseline_samples: counts.length,
|
|
384
|
+
schedule_seconds: seconds(schedule?.[`day_${weekday(date)}`]),
|
|
385
|
+
cap_seconds: seconds(schedule?.cap),
|
|
386
|
+
activities: activities.filter((a) => a.date === date),
|
|
387
|
+
worklog_ids: worklogs.filter((w) => w.date === date).map((w) => w.id),
|
|
388
|
+
...totals(worklogs.filter((w) => w.date === date)),
|
|
389
|
+
};
|
|
390
|
+
});
|
|
391
|
+
const weeks = [];
|
|
392
|
+
for (let date = weekStart; date <= weekEnd; date = shiftDate(date, 7)) {
|
|
393
|
+
const last = shiftDate(date, 6);
|
|
394
|
+
const values = Array.from({ length: 7 }, (_, i) => seconds(schedule?.[`day_${i}`]));
|
|
395
|
+
weeks.push({
|
|
396
|
+
from: date,
|
|
397
|
+
to: last,
|
|
398
|
+
schedule_seconds: values.every((v) => v !== null)
|
|
399
|
+
? values.reduce((sum, v) => sum + (v ?? 0), 0)
|
|
400
|
+
: null,
|
|
401
|
+
...totals(worklogs.filter((w) => typeof w.date === 'string' && w.date >= date && w.date <= last)),
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
owner_id: owner,
|
|
406
|
+
timezone,
|
|
407
|
+
from: selection.from,
|
|
408
|
+
to: selection.to,
|
|
409
|
+
complete: evidenceGaps.length === 0,
|
|
410
|
+
evidence_gaps: [...new Set(evidenceGaps)],
|
|
411
|
+
unplaced_run_ids: unplaced,
|
|
412
|
+
days,
|
|
413
|
+
weeks,
|
|
414
|
+
worklogs,
|
|
415
|
+
tickets,
|
|
416
|
+
projects,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type BbCommandPlugin } from '@bakers-build/core';
|
|
2
|
+
import type { Command } from 'commander';
|
|
3
|
+
type Options = {
|
|
4
|
+
from: string;
|
|
5
|
+
to: string;
|
|
6
|
+
timezone?: string;
|
|
7
|
+
config?: string;
|
|
8
|
+
connection?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function registerTrack(program: Command, read: (options: Options) => Promise<unknown>): void;
|
|
11
|
+
declare const trackCommand: BbCommandPlugin;
|
|
12
|
+
export default trackCommand;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createBbConnectionClient, loadReadyBbConnection, } from '@bakers-build/core';
|
|
2
|
+
import { loadEvidence } from './evidence.js';
|
|
3
|
+
export function registerTrack(program, read) {
|
|
4
|
+
program
|
|
5
|
+
.command('track')
|
|
6
|
+
.description('Personal time evidence')
|
|
7
|
+
.command('evidence')
|
|
8
|
+
.description('Read daily runs, activities and existing time as JSON')
|
|
9
|
+
.requiredOption('--from <date>', 'first civil date (YYYY-MM-DD)')
|
|
10
|
+
.requiredOption('--to <date>', 'last civil date (YYYY-MM-DD), inclusive')
|
|
11
|
+
.option('--timezone <zone>', 'IANA timezone; defaults to the employee profile')
|
|
12
|
+
.option('--config <path>', 'connection config path')
|
|
13
|
+
.option('--connection <id>', 'named connection')
|
|
14
|
+
.action(async (options) => {
|
|
15
|
+
const report = await read(options);
|
|
16
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
const trackCommand = {
|
|
20
|
+
kind: 'command',
|
|
21
|
+
name: 'track',
|
|
22
|
+
describe: 'read personal daily evidence for time preparation',
|
|
23
|
+
register(program, host) {
|
|
24
|
+
registerTrack(program, async (options) => {
|
|
25
|
+
const connection = await loadReadyBbConnection(host, {
|
|
26
|
+
...(options.config ? { explicit: options.config } : {}),
|
|
27
|
+
...(options.connection ? { connection: options.connection } : {}),
|
|
28
|
+
});
|
|
29
|
+
const client = await createBbConnectionClient(connection);
|
|
30
|
+
const worklogs = await createBbConnectionClient({
|
|
31
|
+
...connection,
|
|
32
|
+
site: { ...connection.site, jsonapi_prefix: '/api/time' },
|
|
33
|
+
});
|
|
34
|
+
return loadEvidence(client, options, worklogs);
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
export default trackCommand;
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakers-build/addon-track",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-next.42",
|
|
4
|
+
"description": "Read personal daily evidence for time preparation.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/src/index.js",
|
|
9
|
+
"./preset": "./dist/src/preset.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist/src"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://git.key-tec.de/bakers-build/bakers-build.git",
|
|
20
|
+
"directory": "bakers-cli/addons/track"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"commander": "^12.1.0",
|
|
24
|
+
"drupal-jsonapi-params": "^3.0.1"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@bakers-build/core": "^0.1.0-next.42"
|
|
28
|
+
}
|
|
29
|
+
}
|