Skip to main content
Glama
pshempel

MCP Time Server Node

by pshempel
calculateBusinessHours.ts12.4 kB
import { formatInTimeZone } from 'date-fns-tz'; import { ValidationError, TimezoneError } from '../adapters/mcp-sdk'; import { CacheTTL } from '../cache/timeCache'; import type { CalculateBusinessHoursParams, CalculateBusinessHoursResult, BusinessHours, WeeklyBusinessHours, DayBusinessHours, } from '../types'; import { validateBusinessHoursStructure, isWorkDay, calculateDayBusinessMinutes, buildDayResult, } from '../utils/businessHoursHelpers'; import { parseDateWithTimezone, parseHolidayDates } from '../utils/businessUtils'; import { getConfig } from '../utils/config'; import { debug } from '../utils/debug'; import { parseTimeInput } from '../utils/parseTimeInput'; import { resolveTimezone } from '../utils/timezoneUtils'; import { validateTimezone, validateDateString, validateArrayLength, LIMITS, } from '../utils/validation'; import { withCache } from '../utils/withCache'; // Default business hours: 9 AM - 5 PM const DEFAULT_BUSINESS_HOURS: BusinessHours = { start: { hour: 9, minute: 0 }, end: { hour: 17, minute: 0 }, }; function isWeeklyBusinessHours( hours: BusinessHours | WeeklyBusinessHours ): hours is WeeklyBusinessHours { return typeof hours === 'object' && !('start' in hours); } /** * Validate input parameters for calculateBusinessHours * @internal */ function validateBusinessHoursParams(params: { start_time: string; end_time: string; holidays: string[]; timezone: string; business_hours?: BusinessHours | WeeklyBusinessHours; }): void { const { start_time, end_time, holidays, timezone, business_hours } = params; // Validate string lengths first validateDateString(start_time, 'start_time'); validateDateString(end_time, 'end_time'); validateArrayLength(holidays, LIMITS.MAX_ARRAY_LENGTH, 'holidays'); // Validate timezone if provided if (timezone && !validateTimezone(timezone)) { debug.error('Invalid timezone: %s', timezone); throw new TimezoneError(`Invalid timezone: ${timezone}`, timezone); } // Validate business hours structure using helper validateBusinessHoursStructure(business_hours); } /** * Validate date range for business hours calculation * @internal */ function validateBusinessDateRange( startDate: Date, endDate: Date, start_time: string, end_time: string ): void { // Validate that end is after start if (endDate < startDate) { debug.error('End time must be after start time: start=%s, end=%s', start_time, end_time); throw new ValidationError('End time must be after start time', { start_time, end_time }); } // DoS protection: Limit date range to 10 years (3,650 days) for business hours const daysDifference = Math.abs(endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24); if (daysDifference > 3650) { debug.error('Date range too large for business hours: %d days (max 3650)', daysDifference); throw new ValidationError( 'Date range exceeds maximum limit of 10 years for business hours calculation', { start_time, end_time, days: Math.floor(daysDifference), max_days: 3650, } ); } } /** * Get business hours for a specific day of week * @internal */ function getBusinessHoursForDay( dayOfWeek: number, businessHours?: BusinessHours | WeeklyBusinessHours ): BusinessHours | null { if (!businessHours) { return DEFAULT_BUSINESS_HOURS; } if (isWeeklyBusinessHours(businessHours)) { // eslint-disable-next-line security/detect-object-injection -- Day of week index (0-6) return businessHours[dayOfWeek] ?? DEFAULT_BUSINESS_HOURS; } return businessHours; } /** * Build cache key for business hours calculation * @internal */ function buildBusinessHoursCacheKey(params: { start_time: string; end_time: string; timezone: string; business_hours?: BusinessHours | WeeklyBusinessHours; holidays: string[]; include_weekends: boolean; }): string { const { start_time, end_time, timezone, business_hours, holidays, include_weekends } = params; const businessHoursKey = business_hours ? JSON.stringify(business_hours) : 'default'; return `business_hours_${start_time}_${end_time}_${timezone}_${businessHoursKey}_${holidays.join(',')}_${include_weekends}`; } /** * Process all days in date range and calculate business hours * @internal */ function processDateRange(params: { dateStrings: string[]; startDate: Date; endDate: Date; timezone: string; holidayDates: Date[]; include_weekends: boolean; business_hours?: BusinessHours | WeeklyBusinessHours; }): { breakdown: DayBusinessHours[]; totalMinutes: number } { const { dateStrings, startDate, endDate, timezone, holidayDates, include_weekends, business_hours, } = params; const breakdown: DayBusinessHours[] = []; let totalBusinessMinutes = 0; for (const dayDateStr of dateStrings) { // Get day information const day = parseTimeInput(dayDateStr + 'T12:00:00', timezone).date; const dayOfWeek = parseInt(formatInTimeZone(day, timezone, 'c'), 10) - 1; const isWeekendDay = dayOfWeek === 0 || dayOfWeek === 6; // Get business hours for this day const dayBusinessHours = getBusinessHoursForDay(dayOfWeek, business_hours); // Process the single day const { dayResult, minutes } = processSingleBusinessDay({ dayDateStr, startDate, endDate, timezone, businessHours: dayBusinessHours, holidayDates, include_weekends, dayOfWeek, isWeekend: isWeekendDay, }); breakdown.push(dayResult); totalBusinessMinutes += minutes; } return { breakdown, totalMinutes: totalBusinessMinutes }; } /** * Generate array of date strings between start and end dates * @internal */ export function generateDateRange(startDate: Date, endDate: Date, timezone: string): string[] { const datesInBizTz = new Set<string>(); let currentDate = new Date(startDate); while (currentDate <= endDate) { datesInBizTz.add(formatInTimeZone(currentDate, timezone, 'yyyy-MM-dd')); currentDate = new Date(currentDate.getTime() + 24 * 60 * 60 * 1000); // Add 24 hours } // Always include the end date datesInBizTz.add(formatInTimeZone(endDate, timezone, 'yyyy-MM-dd')); return Array.from(datesInBizTz).sort(); } /** * Process a single day and calculate business hours * @internal */ export function processSingleBusinessDay(params: { dayDateStr: string; startDate: Date; endDate: Date; timezone: string; businessHours: BusinessHours | null; holidayDates: Date[]; include_weekends: boolean; dayOfWeek: number; isWeekend: boolean; }): { dayResult: DayBusinessHours; minutes: number; } { const { dayDateStr, startDate, endDate, timezone, businessHours, holidayDates, include_weekends, dayOfWeek, isWeekend, } = params; // Get day name from day of week // eslint-disable-next-line security/detect-object-injection -- dayOfWeek is calculated (0-6), not user input const dayName = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][ dayOfWeek ]; // Check if this is a work day const isWorkingDay = isWorkDay( dayDateStr, dayOfWeek, isWeekend, holidayDates, include_weekends, timezone ); // Log work day decision if (!isWorkingDay) { const isHolidayDay = holidayDates.some((h) => h.toISOString().slice(0, 10) === dayDateStr); debug.decision('Day excluded', { date: dayDateStr, dayName, reason: isHolidayDay ? 'holiday' : isWeekend && !include_weekends ? 'weekend' : 'no-hours', }); } let minutes = 0; if (isWorkingDay && businessHours) { // Create business hours start/end times in business timezone const bizStartStr = `${dayDateStr}T${String(businessHours.start.hour).padStart(2, '0')}:${String(businessHours.start.minute).padStart(2, '0')}:00`; const bizEndStr = `${dayDateStr}T${String(businessHours.end.hour).padStart(2, '0')}:${String(businessHours.end.minute).padStart(2, '0')}:00`; const dayBusinessStart = parseTimeInput(bizStartStr, timezone).date; const dayBusinessEnd = parseTimeInput(bizEndStr, timezone).date; // Calculate minutes using helper minutes = calculateDayBusinessMinutes( dayDateStr, businessHours, dayBusinessStart, dayBusinessEnd, startDate, endDate ); // Log partial day calculations if (minutes > 0 && (dayBusinessStart < startDate || dayBusinessEnd > endDate)) { debug.decision('Partial day calculated', { date: dayDateStr, fullDayMinutes: (businessHours.end.hour - businessHours.start.hour) * 60 + (businessHours.end.minute - businessHours.start.minute), actualMinutes: minutes, startClamped: dayBusinessStart < startDate, endClamped: dayBusinessEnd > endDate, }); } } // Check if holiday (needed for result structure) // Use formatInTimeZone to be consistent with date string generation const isHoliday = holidayDates.some( (h) => formatInTimeZone(h, timezone, 'yyyy-MM-dd') === dayDateStr ); // Build day result using helper const dayResult = buildDayResult(dayDateStr, dayName, minutes, isWeekend, isHoliday); return { dayResult, minutes }; } /** * Build the final business hours result from breakdown * @internal */ export function buildBusinessHoursResult( breakdown: DayBusinessHours[], totalMinutes: number ): CalculateBusinessHoursResult { const result: CalculateBusinessHoursResult = { total_business_minutes: totalMinutes, total_business_hours: totalMinutes / 60, breakdown, }; // Log summary debug.business( 'Calculated %d business hours across %d days (%d working days)', Math.round(totalMinutes / 60), breakdown.length, breakdown.filter((d) => d.business_minutes > 0).length ); return result; } export function calculateBusinessHours( params: CalculateBusinessHoursParams ): CalculateBusinessHoursResult { const { start_time, end_time, holidays = [], include_weekends = false } = params; // Log entry with parameters debug.business('calculateBusinessHours called with params: %O', { start_time, end_time, timezone: params.timezone, business_hours: params.business_hours ?? 'default', holidays_count: holidays.length, include_weekends, }); const config = getConfig(); const timezone = resolveTimezone(params.timezone, config.defaultTimezone); // Validate all input parameters validateBusinessHoursParams({ start_time, end_time, holidays, timezone, business_hours: params.business_hours, }); // Build cache key const cacheKey = buildBusinessHoursCacheKey({ start_time, end_time, timezone, business_hours: params.business_hours, holidays, include_weekends, }); // Use withCache wrapper for the calculation return withCache(cacheKey, CacheTTL.CALCULATIONS, () => { // Parse dates const startDate = parseDateWithTimezone(start_time, timezone, 'start_time'); const endDate = parseDateWithTimezone(end_time, timezone, 'end_time'); // Validate date range validateBusinessDateRange(startDate, endDate, start_time, end_time); // Log the calculation context debug.business( 'Business hours calculation: %s to %s in %s', formatInTimeZone(startDate, timezone, 'yyyy-MM-dd HH:mm'), formatInTimeZone(endDate, timezone, 'yyyy-MM-dd HH:mm'), timezone ); // Parse holiday dates const holidayDates = parseHolidayDates(holidays, timezone); if (holidayDates.length > 0) { debug.business('Holidays to exclude: %O', holidayDates); } // Generate date range to process const dateStrings = generateDateRange(startDate, endDate, timezone); // Process all days in the date range const { breakdown, totalMinutes } = processDateRange({ dateStrings, startDate, endDate, timezone, holidayDates, include_weekends, business_hours: params.business_hours, }); // Build and return the final result const result = buildBusinessHoursResult(breakdown, totalMinutes); // Log successful completion debug.business( 'calculateBusinessHours completed successfully: %d total hours', result.total_business_hours ); return result; }); }

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/pshempel/mcp-time-server-node'

If you have feedback or need assistance with the MCP directory API, please join our Discord server