Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
201 lines
6.6 KiB
TypeScript
201 lines
6.6 KiB
TypeScript
import type { IScheduleEntry, IHoliday } from '@alga-psa/types';
|
|
import * as rrulePkg from 'rrule';
|
|
import type { Frequency, Weekday } from 'rrule';
|
|
|
|
type RRuleConstructor = typeof import('rrule').RRule;
|
|
|
|
const rruleExports = rrulePkg as unknown as {
|
|
RRule?: RRuleConstructor;
|
|
default?: { RRule?: RRuleConstructor };
|
|
};
|
|
|
|
function resolveRRule(): RRuleConstructor {
|
|
const resolved = rruleExports.RRule ?? rruleExports.default?.RRule;
|
|
if (!resolved) {
|
|
throw new Error('Unable to resolve RRule export from rrule package');
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
const RRule = resolveRRule();
|
|
|
|
/**
|
|
* Helper to format a date as YYYY-MM-DD string.
|
|
*/
|
|
function formatDateString(date: Date): string {
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
/**
|
|
* Check if a date falls on a holiday.
|
|
* Handles both one-time and recurring (annual) holidays.
|
|
*/
|
|
export function isHolidayDate(date: Date, holidays: IHoliday[]): boolean {
|
|
if (!holidays || holidays.length === 0) return false;
|
|
|
|
const dateStr = formatDateString(date);
|
|
|
|
return holidays.some(holiday => {
|
|
if (holiday.is_recurring) {
|
|
// For recurring holidays, compare only month and day (MM-DD)
|
|
const holidayMonthDay = holiday.holiday_date.slice(5);
|
|
const dateMonthDay = dateStr.slice(5);
|
|
return holidayMonthDay === dateMonthDay;
|
|
}
|
|
return holiday.holiday_date === dateStr;
|
|
});
|
|
}
|
|
|
|
export interface GenerateOccurrencesOptions {
|
|
/** Holidays to exclude from generated occurrences */
|
|
holidays?: IHoliday[];
|
|
}
|
|
|
|
export function generateOccurrences(
|
|
entry: IScheduleEntry,
|
|
start: Date,
|
|
end: Date,
|
|
options?: GenerateOccurrencesOptions
|
|
): Date[] {
|
|
try {
|
|
if (!entry.recurrence_pattern) {
|
|
return [new Date(entry.scheduled_start)];
|
|
}
|
|
const pattern = entry.recurrence_pattern;
|
|
|
|
// Validate and normalize start date
|
|
const dtstart = new Date(pattern.startDate);
|
|
if (isNaN(dtstart.getTime())) {
|
|
console.error('[generateOccurrences] Invalid start date:', pattern.startDate);
|
|
return [new Date(entry.scheduled_start)];
|
|
}
|
|
dtstart.setHours(0, 0, 0, 0);
|
|
|
|
// If end date exists, validate and normalize it
|
|
let until: Date | undefined;
|
|
if (pattern.endDate) {
|
|
until = new Date(pattern.endDate);
|
|
if (isNaN(until.getTime())) {
|
|
console.error('[generateOccurrences] Invalid end date:', pattern.endDate);
|
|
return [new Date(entry.scheduled_start)];
|
|
}
|
|
until.setHours(23, 59, 59, 999);
|
|
}
|
|
|
|
// Create RRule with error handling for frequency
|
|
const freqKey = pattern.frequency.toUpperCase() as keyof typeof RRule;
|
|
if (!(freqKey in RRule)) {
|
|
console.error('[generateOccurrences] Invalid frequency:', pattern.frequency);
|
|
return [new Date(entry.scheduled_start)];
|
|
}
|
|
const rrule = new RRule({
|
|
freq: RRule[freqKey] as Frequency,
|
|
interval: pattern.interval,
|
|
dtstart,
|
|
until,
|
|
byweekday: pattern.daysOfWeek?.map((day): Weekday => {
|
|
const days = 'MO TU WE TH FR SA SU'.split(' ');
|
|
if (day < 0 || day >= days.length) {
|
|
console.error('[generateOccurrences] Invalid day of week:', day);
|
|
return RRule.MO;
|
|
}
|
|
return RRule[days[day] as keyof typeof RRule] as Weekday;
|
|
}),
|
|
bymonthday: pattern.dayOfMonth,
|
|
bymonth: pattern.monthOfYear,
|
|
count: pattern.count
|
|
});
|
|
|
|
// Normalize and validate range dates
|
|
const rangeStart = new Date(start);
|
|
if (isNaN(rangeStart.getTime())) {
|
|
console.error('[generateOccurrences] Invalid range start date:', start);
|
|
return [new Date(entry.scheduled_start)];
|
|
}
|
|
rangeStart.setHours(0, 0, 0, 0);
|
|
rangeStart.setSeconds(rangeStart.getSeconds() - 1);
|
|
|
|
const rangeEnd = new Date(end);
|
|
if (isNaN(rangeEnd.getTime())) {
|
|
console.error('[generateOccurrences] Invalid range end date:', end);
|
|
return [new Date(entry.scheduled_start)];
|
|
}
|
|
rangeEnd.setHours(23, 59, 59, 999);
|
|
|
|
// Get the base occurrences using normalized dates
|
|
const baseOccurrences = rrule.between(rangeStart, rangeEnd);
|
|
|
|
// Validate and get the original time
|
|
const originalTime = new Date(entry.scheduled_start);
|
|
if (isNaN(originalTime.getTime())) {
|
|
console.error('[generateOccurrences] Invalid scheduled start time:', entry.scheduled_start);
|
|
return baseOccurrences;
|
|
}
|
|
|
|
// Filter out the master entry's start date and apply the original time to each occurrence
|
|
const masterStartDate = new Date(entry.scheduled_start);
|
|
const occurrencesWithTime = baseOccurrences
|
|
.filter((date): boolean => {
|
|
const dateStr = date.toISOString().split('T')[0];
|
|
const masterStr = masterStartDate.toISOString().split('T')[0];
|
|
return dateStr !== masterStr;
|
|
})
|
|
.map((date): Date => applyTimeToDate(date, originalTime));
|
|
|
|
// Apply exceptions with validation
|
|
let filteredOccurrences = occurrencesWithTime;
|
|
if (pattern.exceptions && Array.isArray(pattern.exceptions)) {
|
|
try {
|
|
const validExceptions = pattern.exceptions
|
|
.map((d): Date | null => {
|
|
try {
|
|
const date = d instanceof Date ? d : new Date(d);
|
|
if (isNaN(date.getTime())) {
|
|
return null;
|
|
}
|
|
return date;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter((d): d is Date => d !== null);
|
|
|
|
const exceptionDates = validExceptions.map((d): string => d.toISOString().split('T')[0]);
|
|
|
|
filteredOccurrences = occurrencesWithTime.filter((date: Date): boolean => {
|
|
const dateStr = date.toISOString().split('T')[0];
|
|
return !exceptionDates.includes(dateStr);
|
|
});
|
|
} catch (error) {
|
|
console.error('[generateOccurrences] Error processing exceptions:', error);
|
|
}
|
|
}
|
|
|
|
// Filter out holidays (unified holidays table - used by SLA and scheduling)
|
|
if (options?.holidays && options.holidays.length > 0) {
|
|
filteredOccurrences = filteredOccurrences.filter(
|
|
(date: Date): boolean => !isHolidayDate(date, options.holidays!)
|
|
);
|
|
}
|
|
|
|
return filteredOccurrences;
|
|
} catch (error) {
|
|
console.error('[generateOccurrences] Unexpected error:', error);
|
|
return [new Date(entry.scheduled_start)];
|
|
}
|
|
}
|
|
|
|
export function applyTimeToDate(date: Date, time: Date): Date {
|
|
try {
|
|
const result = new Date(date);
|
|
result.setHours(time.getHours(), time.getMinutes(), time.getSeconds(), time.getMilliseconds());
|
|
return result;
|
|
} catch (error) {
|
|
console.error('[applyTimeToDate] Error applying time:', error);
|
|
return date;
|
|
}
|
|
}
|