import { DateTime, Duration } from 'luxon';
export interface TimeConfig {
defaultTz: string;
workHoursStart: string;
workHoursEnd: string;
workDays: string[];
}
export interface TimeSlot {
start_iso: string;
end_iso: string;
}
export interface WorkHours {
start: DateTime;
end: DateTime;
}
export function parseWorkHours(
start: string,
end: string,
tz: string
): WorkHours {
const today = DateTime.now().setZone(tz);
const startTime = today.set({
hour: parseInt(start.split(':')[0] || '0'),
minute: parseInt(start.split(':')[1] || '0'),
second: 0,
millisecond: 0
});
const endTime = today.set({
hour: parseInt(end.split(':')[0] || '0'),
minute: parseInt(end.split(':')[1] || '0'),
second: 0,
millisecond: 0
});
return { start: startTime, end: endTime };
}
export function isWorkday(date: DateTime, workDays: string[]): boolean {
const dayName = date.toFormat('ccc');
return workDays.includes(dayName);
}
export function clampToWorkHours(
dateTime: DateTime,
config: TimeConfig
): DateTime {
const workHours = parseWorkHours(config.workHoursStart, config.workHoursEnd, config.defaultTz);
if (!isWorkday(dateTime, config.workDays)) {
let nextWorkday = dateTime.plus({ days: 1 });
while (!isWorkday(nextWorkday, config.workDays)) {
nextWorkday = nextWorkday.plus({ days: 1 });
}
return nextWorkday.set({
hour: workHours.start.hour,
minute: workHours.start.minute,
second: 0,
millisecond: 0
});
}
const startOfDay = dateTime.set({
hour: workHours.start.hour,
minute: workHours.start.minute,
second: 0,
millisecond: 0
});
const endOfDay = dateTime.set({
hour: workHours.end.hour,
minute: workHours.end.minute,
second: 0,
millisecond: 0
});
if (dateTime < startOfDay) {
return startOfDay;
}
if (dateTime > endOfDay) {
let nextWorkday = dateTime.plus({ days: 1 });
while (!isWorkday(nextWorkday, config.workDays)) {
nextWorkday = nextWorkday.plus({ days: 1 });
}
return nextWorkday.set({
hour: workHours.start.hour,
minute: workHours.start.minute,
second: 0,
millisecond: 0
});
}
return dateTime;
}
export function parseIsoWithTz(isoString: string, tz: string): DateTime {
return DateTime.fromISO(isoString, { zone: tz });
}
export function toIsoString(dateTime: DateTime): string {
return dateTime.toISO();
}
export function getNextWorkdayStart(config: TimeConfig, fromDate?: DateTime): DateTime {
const baseDate = fromDate || DateTime.now().setZone(config.defaultTz);
const workHours = parseWorkHours(config.workHoursStart, config.workHoursEnd, config.defaultTz);
let nextWorkday = baseDate.plus({ days: 1 });
while (!isWorkday(nextWorkday, config.workDays)) {
nextWorkday = nextWorkday.plus({ days: 1 });
}
return nextWorkday.set({
hour: workHours.start.hour,
minute: workHours.start.minute,
second: 0,
millisecond: 0
});
}
export function isWithinWorkHours(
start: DateTime,
end: DateTime,
config: TimeConfig
): boolean {
if (!isWorkday(start, config.workDays) || !isWorkday(end, config.workDays)) {
return false;
}
const workHours = parseWorkHours(config.workHoursStart, config.workHoursEnd, config.defaultTz);
const startTime = start.set({
year: workHours.start.year,
month: workHours.start.month,
day: workHours.start.day
});
const endTime = end.set({
year: workHours.end.year,
month: workHours.end.month,
day: workHours.end.day
});
return start >= workHours.start && end <= workHours.end;
}
export function generateWorkHourSlots(
windowStart: DateTime,
windowEnd: DateTime,
durationMinutes: number,
config: TimeConfig
): TimeSlot[] {
const slots: TimeSlot[] = [];
const duration = Duration.fromObject({ minutes: durationMinutes });
let current = windowStart;
while (current.plus(duration) <= windowEnd) {
const slotEnd = current.plus(duration);
if (isWithinWorkHours(current, slotEnd, config)) {
slots.push({
start_iso: toIsoString(current),
end_iso: toIsoString(slotEnd)
});
}
current = current.plus({ minutes: 15 });
}
return slots;
}
export function parseRelativeTime(
input: string,
tz: string,
now?: DateTime
): { start: DateTime; end: DateTime; didAssumeDate: boolean; didAssumeDuration: boolean } {
const referenceDate = now || DateTime.now().setZone(tz);
const lowerInput = input.toLowerCase().trim();
let didAssumeDate = false;
let didAssumeDuration = false;
let start: DateTime;
let end: DateTime;
if (lowerInput.includes('next')) {
const dayMatch = lowerInput.match(/next\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday|mon|tue|wed|thu|fri|sat|sun)/i);
if (dayMatch && dayMatch[1]) {
const targetDay = dayMatch[1].toLowerCase();
const dayMap: Record<string, number> = {
'monday': 1, 'mon': 1,
'tuesday': 2, 'tue': 2,
'wednesday': 3, 'wed': 3,
'thursday': 4, 'thu': 4,
'friday': 5, 'fri': 5,
'saturday': 6, 'sat': 6,
'sunday': 7, 'sun': 7
};
const targetDayNum = dayMap[targetDay];
let nextDay = referenceDate.plus({ days: 1 });
while (nextDay.weekday !== targetDayNum) {
nextDay = nextDay.plus({ days: 1 });
}
const timeMatch = lowerInput.match(/(\d{1,2}):?(\d{2})?\s*(am|pm)?/i);
if (timeMatch) {
let hour = parseInt(timeMatch[1] || '0');
const minute = timeMatch[2] ? parseInt(timeMatch[2]) : 0;
const period = timeMatch[3]?.toLowerCase();
if (period === 'pm' && hour < 12) hour += 12;
if (period === 'am' && hour === 12) hour = 0;
start = nextDay.set({ hour, minute, second: 0, millisecond: 0 });
} else {
start = nextDay.set({ hour: 9, minute: 0, second: 0, millisecond: 0 });
didAssumeDate = true;
}
end = start.plus({ minutes: 30 });
didAssumeDuration = true;
}
}
else if (lowerInput.match(/^\d{1,2}:?\d{0,2}\s*(am|pm)?$/i)) {
const timeMatch = lowerInput.match(/(\d{1,2}):?(\d{2})?\s*(am|pm)?/i);
if (timeMatch) {
let hour = parseInt(timeMatch[1] || '0');
const minute = timeMatch[2] ? parseInt(timeMatch[2]) : 0;
const period = timeMatch[3]?.toLowerCase();
if (period === 'pm' && hour < 12) hour += 12;
if (period === 'am' && hour === 12) hour = 0;
start = referenceDate.set({ hour, minute, second: 0, millisecond: 0 });
end = start.plus({ minutes: 30 });
didAssumeDuration = true;
} else {
throw new Error('Invalid time format');
}
}
else if (lowerInput.includes('tomorrow')) {
const tomorrow = referenceDate.plus({ days: 1 });
const timeMatch = lowerInput.match(/(\d{1,2}):?(\d{2})?\s*(am|pm)?/i);
if (timeMatch) {
let hour = parseInt(timeMatch[1] || '0');
const minute = timeMatch[2] ? parseInt(timeMatch[2]) : 0;
const period = timeMatch[3]?.toLowerCase();
if (period === 'pm' && hour < 12) hour += 12;
if (period === 'am' && hour === 12) hour = 0;
start = tomorrow.set({ hour, minute, second: 0, millisecond: 0 });
} else {
start = tomorrow.set({ hour: 9, minute: 0, second: 0, millisecond: 0 });
didAssumeDate = true;
}
end = start.plus({ minutes: 30 });
didAssumeDuration = true;
}
else {
throw new Error('Unable to parse time expression');
}
return { start, end, didAssumeDate, didAssumeDuration };
}
export function validateDuration(
start: DateTime,
end: DateTime,
minMinutes: number = 15,
maxMinutes: number = 480
): boolean {
const duration = end.diff(start, 'minutes').minutes;
return duration >= minMinutes && duration <= maxMinutes;
}