Skip to main content
Glama
duration-parser.ts3.82 kB
// src/services/duration-parser.ts import type { Duration } from '../types/index.js'; /** * Duration validation constants */ const MIN_DURATION_HOURS = 0.05; // 3 minutes minimum const MAX_DURATION_HOURS = 24; // 24 hours maximum per entry /** * Validate duration is within reasonable bounds */ function validateDuration(hours: number, input: string): void { if (hours < MIN_DURATION_HOURS) { throw new Error(`Duration too short: "${input}". Minimum duration is 3 minutes (0.05h)`); } if (hours > MAX_DURATION_HOURS) { throw new Error(`Duration too long: "${input}". Maximum duration is 24 hours per entry`); } if (hours < 0) { throw new Error(`Duration cannot be negative: "${input}"`); } if (!isFinite(hours)) { throw new Error(`Invalid duration value: "${input}"`); } } /** * Parse duration string into decimal hours * Supports multiple formats: * - "2h", "2 hours", "2.5h" * - "90m", "90 minutes" * - "PT2H30M" (ISO 8601) * - "half an hour", "quarter hour" */ export function parseDuration(input: string): Duration { const normalized = input.toLowerCase().trim(); // Handle natural language if (normalized.match(/half\s*(an?\s*)?hour/)) { const hours = 0.5; validateDuration(hours, input); return { hours, formatted: '0.5h' }; } if (normalized.match(/quarter\s*(of\s*an?\s*)?hour/)) { const hours = 0.25; validateDuration(hours, input); return { hours, formatted: '0.25h' }; } // Parse hours: "2h", "2 hours", "2.5h" const hoursMatch = normalized.match(/^(\d+(?:\.\d+)?)\s*(hour|hr|h)s?$/); if (hoursMatch) { const hours = parseFloat(hoursMatch[1]); validateDuration(hours, input); return { hours, formatted: `${hours}h` }; } // Parse minutes: "90m", "90 minutes" const minutesMatch = normalized.match(/^(\d+)\s*(minute|min|m)s?$/); if (minutesMatch) { const minutes = parseInt(minutesMatch[1]); const hours = minutes / 60; validateDuration(hours, input); return { hours, formatted: `${hours.toFixed(2)}h` }; } // Parse ISO 8601 duration: PT2H30M const isoMatch = normalized.match(/^pt(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?$/); if (isoMatch) { const [, hoursStr, minutesStr, secondsStr] = isoMatch; let totalHours = 0; if (hoursStr) totalHours += parseInt(hoursStr); if (minutesStr) totalHours += parseInt(minutesStr) / 60; if (secondsStr) totalHours += parseFloat(secondsStr) / 3600; validateDuration(totalHours, input); return { hours: totalHours, formatted: `${totalHours.toFixed(2)}h` }; } // Try just a number (assume hours) const numberMatch = normalized.match(/^(\d+(?:\.\d+)?)$/); if (numberMatch) { const hours = parseFloat(numberMatch[1]); validateDuration(hours, input); return { hours, formatted: `${hours}h` }; } throw new Error(`Unable to parse duration: "${input}". Try formats like "2h", "90m", or "1.5h"`); } /** * Format hours as readable duration * Examples: 2.5 → "2.5h", 0.75 → "0.75h" */ export function formatDuration(hours: number): string { // Use 1 decimal place if needed, otherwise no decimals if (hours % 1 === 0) { return `${hours}h`; } return `${hours.toFixed(1)}h`; } /** * Convert hours to hours and minutes * Example: 2.5 → "2h 30m" */ export function formatDurationDetailed(hours: number): string { const wholeHours = Math.floor(hours); const minutes = Math.round((hours - wholeHours) * 60); if (minutes === 0) { return `${wholeHours}h`; } if (wholeHours === 0) { return `${minutes}m`; } return `${wholeHours}h ${minutes}m`; }

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/markwharton/time-tracking-mcp'

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