UNPKG

@taazkareem/clickup-mcp-server

Version:

ClickUp MCP Server - Integrate ClickUp tasks with AI through Model Context Protocol

543 lines (542 loc) 23.2 kB
/** * SPDX-FileCopyrightText: © 2025 Talib Kareem <taazkareem@icloud.com> * SPDX-License-Identifier: MIT * * Date Utility Functions * * This module provides utilities for handling dates, timestamps, and due date parsing. */ import { Logger } from '../logger.js'; // Create a logger instance for date utilities const logger = new Logger('DateUtils'); /** * Get a timestamp for a relative time * * @param minutes Minutes from now * @param hours Hours from now * @param days Days from now * @param weeks Weeks from now * @param months Months from now * @returns Timestamp in milliseconds */ export function getRelativeTimestamp(minutes = 0, hours = 0, days = 0, weeks = 0, months = 0) { const now = new Date(); if (minutes) now.setMinutes(now.getMinutes() + minutes); if (hours) now.setHours(now.getHours() + hours); if (days) now.setDate(now.getDate() + days); if (weeks) now.setDate(now.getDate() + (weeks * 7)); if (months) now.setMonth(now.getMonth() + months); return now.getTime(); } /** * Get the start of today (midnight) in Unix milliseconds * @returns Timestamp in milliseconds for start of current day */ function getStartOfDay() { const now = new Date(); now.setHours(0, 0, 0, 0); return now.getTime(); } /** * Get the end of today (23:59:59.999) in Unix milliseconds * @returns Timestamp in milliseconds for end of current day */ function getEndOfDay() { const now = new Date(); now.setHours(23, 59, 59, 999); return now.getTime(); } /** * Get the current time in Unix milliseconds * @returns Current timestamp in milliseconds */ function getCurrentTimestamp() { return new Date().getTime(); } /** * Smart preprocessing layer for date strings * Normalizes input, handles common variations, and prepares for regex patterns * * @param input Raw date string input * @returns Preprocessed and normalized date string */ function preprocessDateString(input) { if (!input) return input; let processed = input.toLowerCase().trim(); // Normalize common variations and typos const normalizations = [ // Handle "a" and "an" as "1" FIRST (before other patterns) [/\ba\s+(day|week|month|year)\s+ago\b/g, '1 $1 ago'], [/\ba\s+(day|week|month|year)\s+from\s+now\b/g, '1 $1 from now'], [/\ba\s+(day|week|month|year)\s+later\b/g, '1 $1 later'], [/\ban\s+(hour|day|week|month|year)\s+ago\b/g, '1 $1 ago'], [/\ban\s+(hour|day|week|month|year)\s+from\s+now\b/g, '1 $1 from now'], [/\ban\s+(hour|day|week|month|year)\s+later\b/g, '1 $1 later'], [/\bin\s+a\s+(day|week|month|year)\b/g, 'in 1 $1'], [/\bin\s+an\s+(hour|day|week|month|year)\b/g, 'in 1 $1'], // Handle common typos and variations [/\btommorow\b/g, 'tomorrow'], [/\byesterady\b/g, 'yesterday'], [/\btomorrow\s*mornin[g]?\b/g, 'tomorrow 9am'], [/\byesterday\s*mornin[g]?\b/g, 'yesterday 9am'], [/\btomorrow\s*evenin[g]?\b/g, 'tomorrow 6pm'], [/\byesterday\s*evenin[g]?\b/g, 'yesterday 6pm'], [/\btomorrow\s*night\b/g, 'tomorrow 9pm'], [/\byesterday\s*night\b/g, 'yesterday 9pm'], // Normalize time expressions [/\b(\d{1,2})\s*:\s*(\d{2})\s*(a\.?m\.?|p\.?m\.?)\b/g, '$1:$2$3'], [/\b(\d{1,2})\s*(a\.?m\.?|p\.?m\.?)\b/g, '$1$2'], [/\ba\.?m\.?\b/g, 'am'], [/\bp\.?m\.?\b/g, 'pm'], // Normalize "at" usage and additional time connectors [/\s+at\s+/g, ' '], [/\s+@\s+/g, ' '], [/\s+around\s+/g, ' '], [/\s+by\s+/g, ' '], [/\s+on\s+/g, ' '], // Handle "day after tomorrow" and "day before yesterday" + additional variations [/\bday\s+after\s+tomorrow\b/g, '+2 days'], [/\bday\s+before\s+yesterday\b/g, '-2 days'], [/\bovermorrow\b/g, '+2 days'], // Formal term for "day after tomorrow" [/\bereyesterday\b/g, '-2 days'], // Formal term for "day before yesterday" // Handle "next/last" with time units [/\bnext\s+(\d+)\s+days?\b/g, '+$1 days'], [/\bnext\s+(\d+)\s+weeks?\b/g, '+$1 weeks'], [/\blast\s+(\d+)\s+days?\b/g, '-$1 days'], [/\blast\s+(\d+)\s+weeks?\b/g, '-$1 weeks'], // Normalize relative expressions - comprehensive natural language support [/\bin\s+(\d+)\s+days?\b/g, '+$1 days'], [/\b(\d+)\s+days?\s+ago\b/g, '-$1 days'], [/\bin\s+(\d+)\s+weeks?\b/g, '+$1 weeks'], [/\b(\d+)\s+weeks?\s+ago\b/g, '-$1 weeks'], [/\b(\d+)\s+weeks?\s+from\s+now\b/g, '+$1 weeks'], [/\b(\d+)\s+days?\s+from\s+now\b/g, '+$1 days'], // Additional natural language variations [/\b(\d+)\s+days?\s+later\b/g, '+$1 days'], [/\b(\d+)\s+weeks?\s+later\b/g, '+$1 weeks'], [/\bafter\s+(\d+)\s+days?\b/g, '+$1 days'], [/\bafter\s+(\d+)\s+weeks?\b/g, '+$1 weeks'], [/\b(\d+)\s+days?\s+ahead\b/g, '+$1 days'], [/\b(\d+)\s+weeks?\s+ahead\b/g, '+$1 weeks'], [/\b(\d+)\s+days?\s+forward\b/g, '+$1 days'], [/\b(\d+)\s+weeks?\s+forward\b/g, '+$1 weeks'], // Past variations [/\b(\d+)\s+days?\s+back\b/g, '-$1 days'], [/\b(\d+)\s+weeks?\s+back\b/g, '-$1 weeks'], [/\b(\d+)\s+days?\s+before\b/g, '-$1 days'], [/\b(\d+)\s+weeks?\s+before\b/g, '-$1 weeks'], [/\b(\d+)\s+days?\s+earlier\b/g, '-$1 days'], [/\b(\d+)\s+weeks?\s+earlier\b/g, '-$1 weeks'], // Extended time units - months and years [/\bin\s+(\d+)\s+months?\b/g, '+$1 months'], [/\b(\d+)\s+months?\s+from\s+now\b/g, '+$1 months'], [/\b(\d+)\s+months?\s+later\b/g, '+$1 months'], [/\bafter\s+(\d+)\s+months?\b/g, '+$1 months'], [/\b(\d+)\s+months?\s+ago\b/g, '-$1 months'], [/\b(\d+)\s+months?\s+back\b/g, '-$1 months'], [/\b(\d+)\s+months?\s+earlier\b/g, '-$1 months'], [/\bin\s+(\d+)\s+years?\b/g, '+$1 years'], [/\b(\d+)\s+years?\s+from\s+now\b/g, '+$1 years'], [/\b(\d+)\s+years?\s+later\b/g, '+$1 years'], [/\bafter\s+(\d+)\s+years?\b/g, '+$1 years'], [/\b(\d+)\s+years?\s+ago\b/g, '-$1 years'], [/\b(\d+)\s+years?\s+back\b/g, '-$1 years'], [/\b(\d+)\s+years?\s+earlier\b/g, '-$1 years'], // Handle "this" and "next" prefixes more consistently [/\bthis\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/g, '$1'], [/\bnext\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b/g, 'next $1'], // Normalize timezone abbreviations (remove them for now) [/\s+(est|edt|pst|pdt|cst|cdt|mst|mdt)\b/g, ''], // Clean up extra whitespace [/\s+/g, ' '], ]; // Apply all normalizations for (const [pattern, replacement] of normalizations) { processed = processed.replace(pattern, replacement); } return processed.trim(); } /** * Helper function to parse time components and convert to 24-hour format * Reduces code duplication across different date parsing patterns */ function parseTimeComponents(hours, minutes, meridian) { let parsedHours = parseInt(hours); const parsedMinutes = minutes ? parseInt(minutes) : 0; // Convert to 24-hour format if meridian is specified if (meridian?.toLowerCase() === 'pm' && parsedHours < 12) parsedHours += 12; if (meridian?.toLowerCase() === 'am' && parsedHours === 12) parsedHours = 0; return { hours: parsedHours, minutes: parsedMinutes }; } /** * Helper function to set time on a date object with default fallback */ function setTimeOnDate(date, hours, minutes, meridian) { if (hours) { const { hours: parsedHours, minutes: parsedMinutes } = parseTimeComponents(hours, minutes, meridian); date.setHours(parsedHours, parsedMinutes, 0, 0); } else { // Default to end of day if no time specified date.setHours(23, 59, 59, 999); } } /** * Consolidated date patterns with enhanced flexibility */ function getDatePatterns() { return [ // Relative day expressions with optional time { name: 'relative_days', pattern: /^([+-]?\d+)\s+days?(?:\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?$/, handler: (match) => { const days = parseInt(match[1]); const date = new Date(); date.setDate(date.getDate() + days); setTimeOnDate(date, match[2], match[3], match[4]); return date; } }, // Relative week expressions with optional time { name: 'relative_weeks', pattern: /^([+-]?\d+)\s+weeks?(?:\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?$/, handler: (match) => { const weeks = parseInt(match[1]); const date = new Date(); date.setDate(date.getDate() + (weeks * 7)); setTimeOnDate(date, match[2], match[3], match[4]); return date; } }, // Relative month expressions with optional time { name: 'relative_months', pattern: /^([+-]?\d+)\s+months?(?:\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?$/, handler: (match) => { const months = parseInt(match[1]); const date = new Date(); date.setMonth(date.getMonth() + months); setTimeOnDate(date, match[2], match[3], match[4]); return date; } }, // Relative year expressions with optional time { name: 'relative_years', pattern: /^([+-]?\d+)\s+years?(?:\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?$/, handler: (match) => { const years = parseInt(match[1]); const date = new Date(); date.setFullYear(date.getFullYear() + years); setTimeOnDate(date, match[2], match[3], match[4]); return date; } }, // Yesterday/Tomorrow with enhanced time support { name: 'yesterday_tomorrow', pattern: /^(yesterday|tomorrow)(?:\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?$/, handler: (match) => { const isYesterday = match[1] === 'yesterday'; const date = new Date(); date.setDate(date.getDate() + (isYesterday ? -1 : 1)); setTimeOnDate(date, match[2], match[3], match[4]); return date; } } ]; } /** * Parse a due date string into a timestamp * Enhanced with smart preprocessing and consolidated patterns * * @param dateString Date string to parse * @returns Timestamp in milliseconds or undefined if parsing fails */ export function parseDueDate(dateString) { if (!dateString) return undefined; try { // First, try to parse as a direct timestamp const numericValue = Number(dateString); if (!isNaN(numericValue) && numericValue > 0) { // If it's a reasonable timestamp (after year 2000), use it if (numericValue >= 946684800000) { // Jan 1, 2000 (inclusive) return numericValue; } } // Apply smart preprocessing const preprocessed = preprocessDateString(dateString); logger.debug(`Preprocessed date: "${dateString}" -> "${preprocessed}"`); // Handle natural language dates with preprocessed input const lowerDate = preprocessed; // Try enhanced pattern matching first const patterns = getDatePatterns(); for (const pattern of patterns) { const match = lowerDate.match(pattern.pattern); if (match) { const result = pattern.handler(match); if (result && !isNaN(result.getTime())) { logger.debug(`Matched pattern "${pattern.name}" for: ${lowerDate}`); return result.getTime(); } } } // Handle "now" specifically if (lowerDate === 'now') { return getCurrentTimestamp(); } // Handle "today" with different options if (lowerDate === 'today') { return getEndOfDay(); } if (lowerDate === 'today start' || lowerDate === 'start of today') { return getStartOfDay(); } if (lowerDate === 'today end' || lowerDate === 'end of today') { return getEndOfDay(); } // Note: Yesterday/tomorrow patterns are now handled by enhanced patterns above // Handle day names (Monday, Tuesday, etc.) - find next occurrence const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; const dayMatch = lowerDate.match(/\b(sunday|monday|tuesday|wednesday|thursday|friday|saturday)\b/); if (dayMatch) { const targetDayName = dayMatch[1]; const targetDayIndex = dayNames.indexOf(targetDayName); const today = new Date(); const currentDayIndex = today.getDay(); // Calculate days until target day let daysUntilTarget = targetDayIndex - currentDayIndex; if (daysUntilTarget <= 0) { daysUntilTarget += 7; // Next week } // Handle "next" prefix explicitly if (lowerDate.includes('next ')) { daysUntilTarget += 7; } const targetDate = new Date(today); targetDate.setDate(today.getDate() + daysUntilTarget); // Extract time if specified (e.g., "Friday at 3pm", "Saturday 2:30pm") const timeMatch = lowerDate.match(/(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i); setTimeOnDate(targetDate, timeMatch?.[1], timeMatch?.[2], timeMatch?.[3]); return targetDate.getTime(); } // Note: Relative date patterns are now handled by enhanced patterns above // Legacy support for "X from now" patterns const legacyRelativeFormats = [ { regex: /(\d+)\s*minutes?\s*from\s*now/i, handler: (m) => getRelativeTimestamp(m) }, { regex: /(\d+)\s*hours?\s*from\s*now/i, handler: (h) => getRelativeTimestamp(0, h) }, { regex: /(\d+)\s*days?\s*from\s*now/i, handler: (d) => getRelativeTimestamp(0, 0, d) }, { regex: /(\d+)\s*weeks?\s*from\s*now/i, handler: (w) => getRelativeTimestamp(0, 0, 0, w) }, { regex: /(\d+)\s*months?\s*from\s*now/i, handler: (m) => getRelativeTimestamp(0, 0, 0, 0, m) } ]; for (const format of legacyRelativeFormats) { if (format.regex.test(lowerDate)) { const value = parseInt(lowerDate.match(format.regex)[1]); return format.handler(value); } } // Handle specific date formats // Format: MM/DD/YYYY with enhanced time support (handles both "5pm" and "5 pm") const usDateRegex = /^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:\s+(\d{1,2})(?::(\d{1,2}))?\s*(am|pm)?)?$/i; const usDateMatch = lowerDate.match(usDateRegex); if (usDateMatch) { const [_, month, day, year, hours, minutes, meridian] = usDateMatch; const date = new Date(parseInt(year), parseInt(month) - 1, // JS months are 0-indexed parseInt(day)); // Add time if specified setTimeOnDate(date, hours, minutes, meridian); return date.getTime(); } // Handle MM/DD format without year (assume current year) const usDateNoYearRegex = /^(\d{1,2})\/(\d{1,2})(?:\s+(\d{1,2})(?::(\d{1,2}))?\s*(am|pm)?)?$/i; const usDateNoYearMatch = lowerDate.match(usDateNoYearRegex); if (usDateNoYearMatch) { const [_, month, day, hours, minutes, meridian] = usDateNoYearMatch; const currentYear = new Date().getFullYear(); const date = new Date(currentYear, parseInt(month) - 1, // JS months are 0-indexed parseInt(day)); // Add time if specified setTimeOnDate(date, hours, minutes, meridian); return date.getTime(); } // Handle text month formats (e.g., "march 10 2025 6:30pm") const textMonthRegex = /^(january|february|march|april|may|june|july|august|september|october|november|december)\s+(\d{1,2})\s+(\d{4})(?:\s+(\d{1,2})(?::(\d{1,2}))?\s*(am|pm)?)?$/i; const textMonthMatch = lowerDate.match(textMonthRegex); if (textMonthMatch) { const [_, monthName, day, year, hours, minutes, meridian] = textMonthMatch; const monthNames = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december']; const monthIndex = monthNames.indexOf(monthName.toLowerCase()); if (monthIndex !== -1) { const date = new Date(parseInt(year), monthIndex, parseInt(day)); // Add time if specified setTimeOnDate(date, hours, minutes, meridian); return date.getTime(); } } // Enhanced fallback chain with better validation and error handling return enhancedFallbackParsing(dateString, preprocessed); } catch (error) { logger.warn(`Failed to parse due date: ${dateString}`, error); throw new Error(`Invalid date format: ${dateString}`); } } /** * Enhanced fallback parsing with multiple strategies * * @param originalInput Original date string * @param preprocessedInput Preprocessed date string * @returns Timestamp in milliseconds or undefined */ function enhancedFallbackParsing(originalInput, preprocessedInput) { const now = Date.now(); const oneYearAgo = now - (365 * 24 * 60 * 60 * 1000); const tenYearsFromNow = now + (10 * 365 * 24 * 60 * 60 * 1000); /** * Validate if a date is reasonable */ function isReasonableDate(date) { const time = date.getTime(); return !isNaN(time) && time > oneYearAgo && time < tenYearsFromNow; } /** * Try parsing with automatic future adjustment for past dates */ function tryParseWithFutureAdjustment(input) { const date = new Date(input); if (!isReasonableDate(date)) return null; // If the parsed date is in the past and looks like a day of the week, assume next occurrence if (date.getTime() < now && input.match(/monday|tuesday|wednesday|thursday|friday|saturday|sunday/i)) { date.setDate(date.getDate() + 7); } return isReasonableDate(date) ? date : null; } // Strategy 1: Try preprocessed input with native Date constructor let result = tryParseWithFutureAdjustment(preprocessedInput); if (result) { logger.debug(`Fallback strategy 1 succeeded for: ${preprocessedInput}`); return result.getTime(); } // Strategy 2: Try original input with native Date constructor result = tryParseWithFutureAdjustment(originalInput); if (result) { logger.debug(`Fallback strategy 2 succeeded for: ${originalInput}`); return result.getTime(); } // Strategy 3: Try common variations and transformations const variations = [ // Remove common words that might confuse the parser originalInput.replace(/\s+at\s+/gi, ' '), originalInput.replace(/\s+(est|edt|pst|pdt|cst|cdt|mst|mdt)\b/gi, ''), originalInput.replace(/\bnext\s+/gi, ''), originalInput.replace(/\bthis\s+/gi, ''), originalInput.replace(/\bon\s+/gi, ''), // Try with different separators originalInput.replace(/[-\/]/g, '/'), originalInput.replace(/[-\/]/g, '-'), // Try adding current year if it looks like a date without year (() => { const currentYear = new Date().getFullYear(); if (originalInput.match(/^\d{1,2}[\/\-]\d{1,2}$/)) { return `${originalInput}/${currentYear}`; } return originalInput; })(), ]; for (const variation of variations) { if (variation === originalInput) continue; // Skip if no change result = tryParseWithFutureAdjustment(variation); if (result) { logger.debug(`Fallback strategy 3 succeeded with variation: ${variation}`); return result.getTime(); } } // Strategy 4: Last resort - try ISO format variations const isoVariations = [ originalInput.replace(/(\d{4})-(\d{1,2})-(\d{1,2})/, '$1-$2-$3T23:59:59'), originalInput.replace(/(\d{1,2})\/(\d{1,2})\/(\d{4})/, '$3-$1-$2'), ]; for (const isoVariation of isoVariations) { if (isoVariation === originalInput) continue; const date = new Date(isoVariation); if (isReasonableDate(date)) { logger.debug(`Fallback strategy 4 succeeded with ISO variation: ${isoVariation}`); return date.getTime(); } } logger.debug(`All fallback strategies failed for: ${originalInput}`); return undefined; } /** * Format a due date timestamp into a human-readable string * * @param timestamp Unix timestamp in milliseconds * @returns Formatted date string or undefined if timestamp is invalid */ export function formatDueDate(timestamp) { if (!timestamp) return undefined; try { const date = new Date(timestamp); if (isNaN(date.getTime())) return undefined; // Format: "March 10, 2025 at 10:56 PM" return date.toLocaleString('en-US', { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }).replace(' at', ','); } catch (error) { logger.warn(`Failed to format due date: ${timestamp}`, error); throw new Error(`Invalid timestamp: ${timestamp}`); } } /** * Format a date for display in errors and messages * @param timestamp The timestamp to format * @returns A human-readable relative time (e.g., "2 hours ago") */ export function formatRelativeTime(timestamp) { if (!timestamp) return 'Unknown'; const timestampNum = typeof timestamp === 'string' ? parseInt(timestamp, 10) : timestamp; const now = Date.now(); const diffMs = now - timestampNum; // Convert to appropriate time unit const diffSec = Math.floor(diffMs / 1000); if (diffSec < 60) return `${diffSec} seconds ago`; const diffMin = Math.floor(diffSec / 60); if (diffMin < 60) return `${diffMin} minutes ago`; const diffHour = Math.floor(diffMin / 60); if (diffHour < 24) return `${diffHour} hours ago`; const diffDays = Math.floor(diffHour / 24); if (diffDays < 30) return `${diffDays} days ago`; const diffMonths = Math.floor(diffDays / 30); if (diffMonths < 12) return `${diffMonths} months ago`; const diffYears = Math.floor(diffMonths / 12); return `${diffYears} years ago`; }