filegrc 0.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.
@@ -0,0 +1,595 @@
1
+ import { createResourceId } from "./id.js";
2
+ import { createResourceAndLink, createResources, updateResource } from "./files.js";
3
+ import {
4
+ addCalendarDays,
5
+ calendarDayDifference,
6
+ calendarOccurrence,
7
+ calendarOccurrenceIndex,
8
+ parseCalendarDate,
9
+ validCalendarRecurrence
10
+ } from "./recurrence.js";
11
+ import { isRfc3339Timestamp } from "./time.js";
12
+ import { loadWorkspace } from "./workspace.js";
13
+
14
+ const COMPLETION_DATE_FIELDS = [
15
+ "completedOn",
16
+ "performedOn",
17
+ "reviewedOn",
18
+ "assessmentDate",
19
+ "meetingDate",
20
+ "scheduledOn",
21
+ "occurredOn",
22
+ "collectedOn",
23
+ "verifiedOn",
24
+ "approvedOn",
25
+ "submittedOn",
26
+ "closedOn",
27
+ "reportDate",
28
+ "periodEnd"
29
+ ];
30
+ const MAX_PLANNED_ITEMS = 10_000;
31
+ const DEFAULT_EVENT_DEADLINE_DAYS = 30;
32
+
33
+ export function planObligations(resources, options = {}) {
34
+ const asOf = requireDate(options.asOf ?? new Date().toISOString().slice(0, 10), "as-of date");
35
+ const defaultNow = options.asOf ? `${asOf}T23:59:59Z` : new Date().toISOString();
36
+ const now = requireTimestamp(options.now ?? defaultNow, "current timestamp");
37
+ const through = requireDate(options.through ?? addCalendarDays(asOf, 90), "through date");
38
+ const requestedFrom = options.from ? requireDate(options.from, "from date") : null;
39
+ if (through < asOf && !requestedFrom) throw new Error("The through date must not be before the as-of date.");
40
+ if (requestedFrom && through < requestedFrom) throw new Error("The through date must not be before the from date.");
41
+ const records = resources.map((item) => item?.record ?? item).filter(Boolean);
42
+ const byId = new Map(records.map((record) => [record.id, record]));
43
+ const obligations = records.filter((record) => record.type === "obligation" && record.status === "active");
44
+ if (obligations.length > MAX_PLANNED_ITEMS) {
45
+ throw new Error(`The obligation query must be narrowed; it includes more than ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} active obligations.`);
46
+ }
47
+ const calendarItems = [];
48
+ const triggerGroups = new Map();
49
+ let scannedCalendarOccurrences = 0;
50
+ const scanCalendarWindow = (recurrence, window, index) => {
51
+ if (scannedCalendarOccurrences++ >= MAX_PLANNED_ITEMS) {
52
+ throw new Error(`The obligation query must be narrowed; it scans more than ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} calendar occurrences.`);
53
+ }
54
+ return calendarWindow(recurrence, window, index);
55
+ };
56
+
57
+ for (const obligation of obligations) {
58
+ if (obligation.recurrence?.mode === "event" && obligation.recurrence.eventType) {
59
+ const eventType = obligation.recurrence.eventType;
60
+ const group = triggerGroups.get(eventType) ?? {
61
+ eventType,
62
+ prompt: obligation.triggerPrompt || humanize(eventType),
63
+ policyIds: [],
64
+ obligationIds: [],
65
+ steps: []
66
+ };
67
+ group.policyIds.push(...(obligation.policyIds || []));
68
+ group.obligationIds.push(obligation.id);
69
+ group.steps.push({
70
+ obligationId: obligation.id,
71
+ title: obligation.title,
72
+ activityType: obligation.activityType,
73
+ ownerIds: obligation.ownerIds || [],
74
+ policyIds: obligation.policyIds || [],
75
+ controlIds: obligation.controlIds || [],
76
+ scopeResourceIds: obligation.scopeResourceIds || [],
77
+ templateResourceId: obligation.templateResourceId || null,
78
+ completionResourceTypes: obligation.completionResourceTypes || [],
79
+ window: normalizedEventWindow(obligation.window)
80
+ });
81
+ triggerGroups.set(eventType, group);
82
+ continue;
83
+ }
84
+
85
+ const recurrence = {
86
+ ...(obligation.recurrence || {}),
87
+ anchorDate: obligation.recurrence?.anchorDate || obligation.startsOn
88
+ };
89
+ if (!validCalendarRecurrence(recurrence)) continue;
90
+ const from = requestedFrom || recurrence.anchorDate;
91
+ let index = Math.max(0, calendarOccurrenceIndex(recurrence, from));
92
+ while (index > 0) {
93
+ const previousWindow = scanCalendarWindow(recurrence, obligation.window, index);
94
+ if (!previousWindow || previousWindow.overdueOn <= from) break;
95
+ index -= 1;
96
+ }
97
+ for (; ; index += 1) {
98
+ const window = scanCalendarWindow(recurrence, obligation.window, index);
99
+ if (!window || window.dueWindowStart > through) break;
100
+ if (obligation.endsOn && window.dueWindowStart > obligation.endsOn) break;
101
+ if (window.overdueOn <= from) continue;
102
+ const completions = (obligation.completionResourceIds || [])
103
+ .map((id) => byId.get(id))
104
+ .filter((record) => (
105
+ record
106
+ && completionFallsInWindow(record, window)
107
+ && completionTypeMatches(record, obligation.completionResourceTypes)
108
+ ));
109
+ const status = occurrenceStatus(window, asOf, completions.length > 0);
110
+ if (status === "complete" && !options.includeComplete) continue;
111
+ if (calendarItems.length >= MAX_PLANNED_ITEMS) {
112
+ throw new Error(`The obligation query must be narrowed with a later from date; it exceeds ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} calendar occurrences.`);
113
+ }
114
+ calendarItems.push({
115
+ key: `${obligation.id}:${window.dueWindowStart}`,
116
+ kind: "calendar",
117
+ obligationId: obligation.id,
118
+ title: obligation.title,
119
+ activityType: obligation.activityType,
120
+ ownerIds: obligation.ownerIds || [],
121
+ policyIds: obligation.policyIds || [],
122
+ controlIds: obligation.controlIds || [],
123
+ scopeResourceIds: obligation.scopeResourceIds || [],
124
+ completionResourceIds: completions.map((record) => record.id),
125
+ status,
126
+ ...window,
127
+ ...relativeTiming(window, asOf)
128
+ });
129
+ }
130
+ }
131
+
132
+ const events = records.filter((record) => record.type === "obligation-event");
133
+ if (events.length > MAX_PLANNED_ITEMS) {
134
+ throw new Error(`The obligation query must be narrowed; it includes more than ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} event runs.`);
135
+ }
136
+ const eventIds = new Set(events.map((event) => event.id));
137
+ const actionsBySource = new Map();
138
+ let eventActionCount = 0;
139
+ for (const record of records) {
140
+ if (record.type !== "action-item" || !eventIds.has(record.sourceResourceId)) continue;
141
+ if (++eventActionCount > MAX_PLANNED_ITEMS) {
142
+ throw new Error(`The obligation query must be narrowed; it includes more than ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} event actions.`);
143
+ }
144
+ if (!actionsBySource.has(record.sourceResourceId)) actionsBySource.set(record.sourceResourceId, []);
145
+ actionsBySource.get(record.sourceResourceId).push(record);
146
+ }
147
+ const eventRuns = events.map((event) => planEventRun(event, actionsBySource.get(event.id) || [], byId, asOf, now));
148
+ const eventItems = eventRuns
149
+ .filter((run) => run.status !== "canceled")
150
+ .flatMap((run) => run.actions)
151
+ .filter((item) => item.status !== "complete" || options.includeComplete);
152
+ if (calendarItems.length + eventItems.length > MAX_PLANNED_ITEMS) {
153
+ throw new Error(`The obligation query must be narrowed; it exceeds ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} planned items.`);
154
+ }
155
+ const items = [...calendarItems, ...eventItems].sort(comparePlannedItems);
156
+ const counts = { overdue: 0, due: 0, upcoming: 0, complete: 0 };
157
+ for (const item of items) {
158
+ if (counts[item.status] !== undefined) counts[item.status] += 1;
159
+ }
160
+
161
+ return {
162
+ asOf,
163
+ through,
164
+ from: requestedFrom,
165
+ counts,
166
+ items,
167
+ calendarItems,
168
+ eventItems,
169
+ triggers: [...triggerGroups.values()].map((group) => ({
170
+ ...group,
171
+ policyIds: [...new Set(group.policyIds)],
172
+ obligationIds: [...new Set(group.obligationIds)]
173
+ })).sort((a, b) => a.prompt.localeCompare(b.prompt)),
174
+ eventRuns
175
+ };
176
+ }
177
+
178
+ export async function createObligationEvent(input, options) {
179
+ const loaded = await loadWorkspace(input);
180
+ const records = loaded.resources;
181
+ const eventType = String(options?.eventType || "").trim();
182
+ const occurredAt = options?.occurredAt ? requireTimestamp(options.occurredAt, "event timestamp") : null;
183
+ const timestampDate = timestampCalendarDate(occurredAt, loaded.workspace.timezone);
184
+ const occurredOn = requireDate(
185
+ options?.occurredOn || timestampDate,
186
+ "event date"
187
+ );
188
+ if (timestampDate && options?.occurredOn && occurredOn !== timestampDate) {
189
+ throw new Error(`The event date must match the event timestamp in ${loaded.workspace.timezone}.`);
190
+ }
191
+ const templates = records.filter((record) => (
192
+ record.type === "obligation"
193
+ && record.status === "active"
194
+ && record.recurrence?.mode === "event"
195
+ && record.recurrence.eventType === eventType
196
+ ));
197
+ if (!eventType || templates.length === 0) throw new Error(`No active obligations use event type "${eventType}".`);
198
+ if (templates.some((record) => Number.isInteger(normalizedEventWindow(record.window).endOffsetHours)) && !occurredAt) {
199
+ throw new Error(`Event type "${eventType}" has hour-based deadlines and requires an RFC 3339 occurredAt timestamp.`);
200
+ }
201
+ const subjectResourceIds = [...new Set((options.subjectResourceIds || []).map(String).filter(Boolean))];
202
+ const existingIds = records.map((record) => record.id);
203
+ const prompt = templates.find((record) => record.triggerPrompt)?.triggerPrompt || humanize(eventType);
204
+ const title = String(options.title || `${prompt.replace(/\?$/, "")} · ${occurredOn}`).trim();
205
+ const eventId = createResourceId("obligation-event", title, existingIds);
206
+ existingIds.push(eventId);
207
+ const actions = templates.map((obligation) => {
208
+ const id = createResourceId("action-item", `${eventId} ${obligation.title}`, existingIds);
209
+ existingIds.push(id);
210
+ const window = eventWindow(obligation, occurredOn, occurredAt, loaded.workspace.timezone);
211
+ return {
212
+ schemaVersion: 1,
213
+ id,
214
+ type: "action-item",
215
+ title: obligation.title,
216
+ status: "open",
217
+ assigneeIds: obligation.ownerIds || [],
218
+ sourceResourceId: eventId,
219
+ obligationId: obligation.id,
220
+ description: eventActionDescription(obligation, eventType),
221
+ dueWindowStart: window.dueWindowStart,
222
+ ...(window.dueWindowEnd ? { dueWindowEnd: window.dueWindowEnd, dueOn: window.dueWindowEnd } : {}),
223
+ ...(window.overdueOn ? { overdueOn: window.overdueOn } : {}),
224
+ ...(window.dueWindowStartAt ? { dueWindowStartAt: window.dueWindowStartAt } : {}),
225
+ ...(window.dueWindowEndAt ? { dueWindowEndAt: window.dueWindowEndAt, overdueAt: window.dueWindowEndAt } : {})
226
+ };
227
+ });
228
+ const event = {
229
+ schemaVersion: 1,
230
+ id: eventId,
231
+ type: "obligation-event",
232
+ title,
233
+ status: "open",
234
+ eventType,
235
+ occurredOn,
236
+ ...(occurredAt ? { occurredAt } : {}),
237
+ ownerIds: [...new Set(templates.flatMap((record) => record.ownerIds || []))],
238
+ obligationIds: templates.map((record) => record.id),
239
+ actionItemIds: actions.map((record) => record.id),
240
+ ...(subjectResourceIds.length ? { subjectResourceIds } : {})
241
+ };
242
+ await createResources(loaded.root, [event, ...actions]);
243
+ return { event, actions };
244
+ }
245
+
246
+ export async function completeObligationOccurrence(input, options) {
247
+ const loaded = await loadWorkspace(input);
248
+ const obligation = loaded.resources.find((record) => (
249
+ record.type === "obligation" && record.id === options?.obligationId
250
+ ));
251
+ if (!obligation) throw new Error(`Obligation "${options?.obligationId ?? ""}" was not found.`);
252
+ assertExpectedCompletionType(obligation, options?.record);
253
+ return createResourceAndLink(loaded.root, options.record, {
254
+ type: "obligation",
255
+ id: obligation.id,
256
+ field: "completionResourceIds",
257
+ expectedRevision: options.expectedRevision
258
+ }, { content: options.content });
259
+ }
260
+
261
+ export async function completeObligationAction(input, options) {
262
+ const loaded = await loadWorkspace(input);
263
+ const action = loaded.resources.find((record) => (
264
+ record.type === "action-item" && record.id === options?.actionItemId
265
+ ));
266
+ if (!action) throw new Error(`Action item "${options?.actionItemId ?? ""}" was not found.`);
267
+ if (!action.obligationId) throw new Error(`Action item "${action.id}" is not linked to an obligation.`);
268
+ const obligation = loaded.resources.find((record) => (
269
+ record.type === "obligation" && record.id === action.obligationId
270
+ ));
271
+ if (!obligation) throw new Error(`Obligation "${action.obligationId}" was not found.`);
272
+ assertExpectedCompletionType(obligation, options?.record);
273
+ const completedOn = requireDate(options?.completedOn, "completion date");
274
+ const event = loaded.resources.find((record) => record.type === "obligation-event" && record.id === action.sourceResourceId);
275
+ if (event?.occurredOn && completedOn < event.occurredOn) {
276
+ throw new Error("The action completion date cannot be before its policy event date.");
277
+ }
278
+ return createResourceAndLink(loaded.root, options.record, {
279
+ type: "action-item",
280
+ id: action.id,
281
+ field: "completionResourceIds",
282
+ expectedRevision: options.expectedRevision,
283
+ patch: {
284
+ status: "done",
285
+ completedOn
286
+ }
287
+ }, { content: options.content });
288
+ }
289
+
290
+ export async function completeObligationEvent(input, options) {
291
+ const loaded = await loadWorkspace(input);
292
+ const event = loaded.resources.find((record) => (
293
+ record.type === "obligation-event" && record.id === options?.eventId
294
+ ));
295
+ if (!event) throw new Error(`Obligation event "${options?.eventId ?? ""}" was not found.`);
296
+ const completedOn = requireDate(options?.completedOn, "completion date");
297
+ if (completedOn < event.occurredOn) {
298
+ throw new Error("The event completion date cannot be before its occurrence date.");
299
+ }
300
+ const plan = planObligations(loaded.resources, {
301
+ asOf: completedOn,
302
+ through: completedOn,
303
+ includeComplete: true
304
+ });
305
+ const run = plan.eventRuns.find((item) => item.id === event.id);
306
+ if (!run || run.actions.length === 0) {
307
+ throw new Error(`Obligation event "${event.id}" has no action checklist.`);
308
+ }
309
+ const incomplete = run.actions.filter((action) => action.status !== "complete");
310
+ if (incomplete.length) {
311
+ throw new Error(
312
+ `Obligation event "${event.id}" still has incomplete actions: ${incomplete.map((action) => action.actionItemId).join(", ")}.`
313
+ );
314
+ }
315
+ return updateResource(loaded.root, "obligation-event", event.id, {
316
+ ...event,
317
+ status: "complete",
318
+ completedOn
319
+ }, {
320
+ expectedRevision: options.expectedRevision
321
+ });
322
+ }
323
+
324
+ function assertExpectedCompletionType(obligation, record) {
325
+ if (!record || typeof record !== "object" || Array.isArray(record)) {
326
+ throw new Error("A completion resource record is required.");
327
+ }
328
+ const expected = obligation.completionResourceTypes ?? [];
329
+ if (expected.length && !expected.includes(record.type)) {
330
+ throw new Error(
331
+ `Obligation "${obligation.id}" expects a completion resource of type ${expected.join(" or ")}, not "${record.type ?? ""}".`
332
+ );
333
+ }
334
+ }
335
+
336
+ function calendarWindow(recurrence, configuredWindow, index) {
337
+ const occurrence = calendarOccurrence(recurrence, index);
338
+ const next = calendarOccurrence(recurrence, index + 1);
339
+ if (!occurrence || !next) return null;
340
+ const startOffset = Number.isInteger(configuredWindow?.startOffsetDays) ? configuredWindow.startOffsetDays : 0;
341
+ const dueWindowStart = addCalendarDays(occurrence, startOffset);
342
+ const dueWindowEnd = Number.isInteger(configuredWindow?.endOffsetDays)
343
+ ? addCalendarDays(occurrence, configuredWindow.endOffsetDays)
344
+ : addCalendarDays(next, -1);
345
+ const overdueOn = dueWindowEnd ? addCalendarDays(dueWindowEnd, 1) : null;
346
+ if (!dueWindowStart || !dueWindowEnd || !overdueOn) return null;
347
+ return {
348
+ dueWindowStart,
349
+ dueWindowEnd,
350
+ overdueOn
351
+ };
352
+ }
353
+
354
+ function eventWindow(obligation, occurredOn, occurredAt, timezone) {
355
+ const configuredWindow = normalizedEventWindow(obligation.window);
356
+ if (Number.isInteger(configuredWindow.endOffsetHours) && occurredAt) {
357
+ const startOffset = Number.isInteger(configuredWindow.startOffsetHours) ? configuredWindow.startOffsetHours : 0;
358
+ const dueWindowStartAt = addHours(occurredAt, startOffset);
359
+ const dueWindowEndAt = addHours(occurredAt, configuredWindow.endOffsetHours);
360
+ return {
361
+ dueWindowStart: timestampCalendarDate(dueWindowStartAt, timezone),
362
+ dueWindowEnd: timestampCalendarDate(dueWindowEndAt, timezone),
363
+ overdueOn: timestampCalendarDate(dueWindowEndAt, timezone),
364
+ dueWindowStartAt,
365
+ dueWindowEndAt,
366
+ overdueAt: dueWindowEndAt
367
+ };
368
+ }
369
+ const startOffset = Number.isInteger(configuredWindow.startOffsetDays) ? configuredWindow.startOffsetDays : 0;
370
+ const dueWindowStart = addCalendarDays(occurredOn, startOffset);
371
+ const dueWindowEnd = addCalendarDays(occurredOn, configuredWindow.endOffsetDays);
372
+ const overdueOn = dueWindowEnd ? addCalendarDays(dueWindowEnd, 1) : null;
373
+ if (!dueWindowStart || !dueWindowEnd || !overdueOn) {
374
+ throw new Error("Event deadline dates must fall within the supported calendar range.");
375
+ }
376
+ return {
377
+ dueWindowStart,
378
+ dueWindowEnd,
379
+ overdueOn
380
+ };
381
+ }
382
+
383
+ function planEventRun(event, actionItems, byId, asOf, now) {
384
+ const actions = actionItems
385
+ .map((record) => {
386
+ const obligation = byId.get(record.obligationId);
387
+ const expectedCompletionTypes = obligation?.completionResourceTypes || [];
388
+ const linkedCompletionIds = [...new Set([
389
+ ...(record.completionResourceIds || []),
390
+ ...(record.evidenceIds || [])
391
+ ])];
392
+ const matchingCompletionIds = linkedCompletionIds.filter((id) => completionTypeMatches(byId.get(id), expectedCompletionTypes));
393
+ const completionSatisfied = expectedCompletionTypes.length === 0
394
+ || matchingCompletionIds.length > 0;
395
+ const complete = record.status === "done" && completionSatisfied;
396
+ const configuredWindow = normalizedEventWindow(obligation?.window);
397
+ const fallbackEndOffsetDays = Number.isInteger(configuredWindow.endOffsetDays)
398
+ ? configuredWindow.endOffsetDays
399
+ : Math.ceil(configuredWindow.endOffsetHours / 24);
400
+ const dueWindowStart = record.dueWindowStart || event.occurredOn;
401
+ const dueWindowEnd = record.dueWindowEnd || record.dueOn || addCalendarDays(event.occurredOn, fallbackEndOffsetDays);
402
+ const dueWindowStartAt = record.dueWindowStartAt
403
+ || (event.occurredAt && Number.isInteger(configuredWindow.startOffsetHours)
404
+ ? addHours(event.occurredAt, configuredWindow.startOffsetHours)
405
+ : null);
406
+ const dueWindowEndAt = record.dueWindowEndAt
407
+ || (event.occurredAt && Number.isInteger(configuredWindow.endOffsetHours)
408
+ ? addHours(event.occurredAt, configuredWindow.endOffsetHours)
409
+ : null);
410
+ const window = {
411
+ dueWindowStart,
412
+ dueWindowEnd,
413
+ overdueOn: record.overdueOn || (dueWindowEnd ? addCalendarDays(dueWindowEnd, 1) : null),
414
+ dueWindowStartAt,
415
+ dueWindowEndAt,
416
+ overdueAt: record.overdueAt || dueWindowEndAt
417
+ };
418
+ const status = complete
419
+ ? "complete"
420
+ : window.overdueAt && new Date(now) > new Date(window.overdueAt)
421
+ ? "overdue"
422
+ : window.dueWindowStartAt && new Date(now) < new Date(window.dueWindowStartAt)
423
+ ? "upcoming"
424
+ : !window.overdueAt && window.overdueOn && window.overdueOn <= asOf
425
+ ? "overdue"
426
+ : window.dueWindowStart <= asOf
427
+ ? "due"
428
+ : "upcoming";
429
+ return {
430
+ key: record.id,
431
+ kind: "event",
432
+ eventId: event.id,
433
+ actionItemId: record.id,
434
+ obligationId: record.obligationId,
435
+ title: record.title,
436
+ ownerIds: record.assigneeIds || [],
437
+ policyIds: obligation?.policyIds || [],
438
+ controlIds: obligation?.controlIds || [],
439
+ scopeResourceIds: obligation?.scopeResourceIds || [],
440
+ templateResourceId: obligation?.templateResourceId || null,
441
+ completionResourceIds: record.completionResourceIds || [],
442
+ evidenceIds: record.evidenceIds || [],
443
+ expectedCompletionTypes,
444
+ matchingCompletionIds,
445
+ missingCompletion: record.status === "done" && !completionSatisfied,
446
+ canceledAction: record.status === "canceled",
447
+ recordedStatus: record.status,
448
+ completedOn: record.completedOn || null,
449
+ status,
450
+ ...window,
451
+ ...relativeTiming(window, asOf),
452
+ ...relativeTimestampTiming(window, now)
453
+ };
454
+ });
455
+ const derivedStatus = event.status === "canceled"
456
+ ? "canceled"
457
+ : actions.length > 0 && actions.every((item) => item.status === "complete")
458
+ ? "complete"
459
+ : actions.some((item) => item.status === "overdue")
460
+ ? "overdue"
461
+ : actions.length > 0 && actions.every((item) => item.status === "upcoming")
462
+ ? "upcoming"
463
+ : "due";
464
+ return {
465
+ id: event.id,
466
+ title: event.title,
467
+ eventType: event.eventType,
468
+ occurredOn: event.occurredOn,
469
+ occurredAt: event.occurredAt || null,
470
+ subjectResourceIds: event.subjectResourceIds || [],
471
+ actionItemIds: event.actionItemIds || [],
472
+ recordedStatus: event.status,
473
+ status: derivedStatus,
474
+ completeCount: actions.filter((item) => item.status === "complete").length,
475
+ actions
476
+ };
477
+ }
478
+
479
+ function completionFallsInWindow(record, window) {
480
+ const date = completionDate(record);
481
+ return Boolean(date && date >= window.dueWindowStart && date <= window.dueWindowEnd);
482
+ }
483
+
484
+ function normalizedEventWindow(configuredWindow) {
485
+ const window = configuredWindow && !Array.isArray(configuredWindow) && typeof configuredWindow === "object"
486
+ ? configuredWindow
487
+ : {};
488
+ if (Number.isInteger(window.endOffsetDays) || Number.isInteger(window.endOffsetHours)) return window;
489
+ if (Number.isInteger(window.startOffsetHours)) {
490
+ return {
491
+ ...window,
492
+ endOffsetHours: window.startOffsetHours + (DEFAULT_EVENT_DEADLINE_DAYS * 24)
493
+ };
494
+ }
495
+ return {
496
+ ...window,
497
+ startOffsetDays: Number.isInteger(window.startOffsetDays) ? window.startOffsetDays : 0,
498
+ endOffsetDays: DEFAULT_EVENT_DEADLINE_DAYS
499
+ };
500
+ }
501
+
502
+ function completionTypeMatches(record, expectedTypes = []) {
503
+ return Boolean(record && (expectedTypes.length === 0 || expectedTypes.includes(record.type)));
504
+ }
505
+
506
+ function completionDate(record) {
507
+ for (const field of COMPLETION_DATE_FIELDS) {
508
+ if (parseCalendarDate(record[field])) return record[field];
509
+ }
510
+ return null;
511
+ }
512
+
513
+ function occurrenceStatus(window, asOf, complete) {
514
+ if (complete) return "complete";
515
+ if (window.overdueOn <= asOf) return "overdue";
516
+ if (window.dueWindowStart <= asOf) return "due";
517
+ return "upcoming";
518
+ }
519
+
520
+ function relativeTiming(window, asOf) {
521
+ return {
522
+ daysUntilStart: window.dueWindowStart > asOf ? calendarDayDifference(asOf, window.dueWindowStart) : 0,
523
+ daysUntilOverdue: window.overdueOn && window.overdueOn > asOf ? calendarDayDifference(asOf, window.overdueOn) : 0,
524
+ daysOverdue: window.overdueOn && window.overdueOn <= asOf ? calendarDayDifference(window.overdueOn, asOf) : 0
525
+ };
526
+ }
527
+
528
+ function relativeTimestampTiming(window, now) {
529
+ const result = {};
530
+ if (window.dueWindowStartAt) {
531
+ const startDifference = new Date(window.dueWindowStartAt) - new Date(now);
532
+ result.hoursUntilStart = startDifference > 0 ? Math.ceil(startDifference / 3_600_000) : 0;
533
+ }
534
+ if (window.overdueAt) {
535
+ const difference = new Date(window.overdueAt) - new Date(now);
536
+ Object.assign(result, {
537
+ hoursUntilOverdue: difference > 0 ? Math.ceil(difference / 3_600_000) : 0,
538
+ hoursOverdue: difference <= 0 ? Math.floor(Math.abs(difference) / 3_600_000) : 0
539
+ });
540
+ }
541
+ return result;
542
+ }
543
+
544
+ function comparePlannedItems(a, b) {
545
+ const rank = { overdue: 0, due: 1, upcoming: 2, complete: 3 };
546
+ return (rank[a.status] - rank[b.status])
547
+ || String(a.overdueAt || a.overdueOn || a.dueWindowEndAt || a.dueWindowEnd || a.dueWindowStartAt || a.dueWindowStart)
548
+ .localeCompare(String(b.overdueAt || b.overdueOn || b.dueWindowEndAt || b.dueWindowEnd || b.dueWindowStartAt || b.dueWindowStart))
549
+ || a.title.localeCompare(b.title);
550
+ }
551
+
552
+ function eventActionDescription(obligation, eventType) {
553
+ const policy = obligation.policyIds?.length ? ` Policy sources: ${obligation.policyIds.join(", ")}.` : "";
554
+ const scope = obligation.scopeResourceIds?.length ? ` Review scoped resources: ${obligation.scopeResourceIds.join(", ")}.` : "";
555
+ const completion = obligation.completionResourceTypes?.length
556
+ ? ` Link completion records of type ${obligation.completionResourceTypes.join(", ")} and any evidence before marking this done.`
557
+ : " Link the completion record and evidence before marking this done.";
558
+ return `Triggered by ${eventType}.${policy}${scope}${completion}`;
559
+ }
560
+
561
+ function requireDate(value, label) {
562
+ if (!parseCalendarDate(value)) throw new Error(`A valid ${label} is required.`);
563
+ return value;
564
+ }
565
+
566
+ function requireTimestamp(value, label) {
567
+ if (!isRfc3339Timestamp(value)) {
568
+ throw new Error(`A valid RFC 3339 ${label} is required.`);
569
+ }
570
+ return value;
571
+ }
572
+
573
+ function addHours(value, hours) {
574
+ const date = new Date(new Date(value).getTime() + hours * 3_600_000);
575
+ if (Number.isNaN(date.valueOf())) throw new Error("Event deadline timestamps must fall within the supported calendar range.");
576
+ const result = date.toISOString();
577
+ if (!isRfc3339Timestamp(result)) throw new Error("Event deadline timestamps must fall within the supported calendar range.");
578
+ return result;
579
+ }
580
+
581
+ function timestampCalendarDate(value, timezone) {
582
+ if (!value) return null;
583
+ const parts = new Intl.DateTimeFormat("en-US", {
584
+ timeZone: timezone,
585
+ year: "numeric",
586
+ month: "2-digit",
587
+ day: "2-digit"
588
+ }).formatToParts(new Date(value));
589
+ const fields = Object.fromEntries(parts.map((part) => [part.type, part.value]));
590
+ return `${fields.year}-${fields.month}-${fields.day}`;
591
+ }
592
+
593
+ function humanize(value) {
594
+ return String(value).replace(/[-_]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
595
+ }