@bakers-build/addon-track 0.0.0-stage → 0.1.0-next.38

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 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
- # Temporary Holding Version
1
+ # @bakers-build/addon-track
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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;
@@ -0,0 +1,2 @@
1
+ import type { BbPreset } from '@bakers-build/core';
2
+ export declare const commands: BbPreset['commands'];
@@ -0,0 +1,8 @@
1
+ export const commands = (acc) => [
2
+ ...acc,
3
+ {
4
+ name: 'track',
5
+ plugin: '@bakers-build/addon-track',
6
+ describe: 'read personal daily evidence for time preparation',
7
+ },
8
+ ];
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "@bakers-build/addon-track",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-next.38",
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.38"
28
+ }
29
+ }