import dayjs from 'dayjs'; import duration from 'dayjs/plugin/duration'; import relativeTime from 'dayjs/plugin/relativeTime'; import isToday from 'dayjs/plugin/isToday'; import isYesterday from 'dayjs/plugin/isYesterday'; import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; import weekOfYear from 'dayjs/plugin/weekOfYear'; import parse from 'parse-duration'; import { getUserTimezone, getWeekStart } from './settings'; import updateLocale from 'dayjs/plugin/updateLocale'; import { computed } from 'vue'; import { formatNumber } from './number'; export type DateFormat = | 'point-separated-d-m-yyyy' | 'slash-separated-mm-dd-yyyy' | 'slash-separated-dd-mm-yyyy' | 'hyphen-separated-dd-mm-yyyy' | 'hyphen-separated-mm-dd-yyyy' | 'hyphen-separated-yyyy-mm-dd'; // Day of week index type for calendar components (0 = Sunday, 6 = Saturday) export type WeekStartDay = 0 | 1 | 2 | 3 | 4 | 5 | 6; const dateFormatMap: Record = { 'point-separated-d-m-yyyy': 'D.M.YYYY', 'slash-separated-mm-dd-yyyy': 'MM/DD/YYYY', 'slash-separated-dd-mm-yyyy': 'DD/MM/YYYY', 'hyphen-separated-dd-mm-yyyy': 'DD-MM-YYYY', 'hyphen-separated-mm-dd-yyyy': 'MM-DD-YYYY', 'hyphen-separated-yyyy-mm-dd': 'YYYY-MM-DD', }; export type TimeFormat = '12-hours' | '24-hours'; export type IntervalFormat = | 'decimal' | 'hours-minutes' | 'hours-minutes-colon-separated' | 'hours-minutes-seconds-colon-separated'; function configureParseLocale(numberFormat?: string) { switch (numberFormat) { case 'point-comma': parse.unit.group = '.'; parse.unit.decimal = ','; break; case 'space-comma': parse.unit.group = ' '; parse.unit.decimal = ','; break; case 'space-point': parse.unit.group = ' '; parse.unit.decimal = '.'; break; case 'apostrophe-point': parse.unit.group = "'"; parse.unit.decimal = '.'; break; default: // 'comma-point' or unset — default English parse.unit.group = ','; parse.unit.decimal = '.'; break; } } dayjs.extend(relativeTime); dayjs.extend(isToday); dayjs.extend(isYesterday); dayjs.extend(isSameOrBefore); dayjs.extend(isSameOrAfter); dayjs.extend(duration); dayjs.extend(utc); dayjs.extend(timezone); dayjs.extend(updateLocale); dayjs.extend(weekOfYear); export function getDayJsInstance() { dayjs.updateLocale('en', { weekStart: firstDayIndex.value, }); return dayjs; } export const firstDayIndex = computed(() => { const apiDayOrder = [ 'sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', ]; return apiDayOrder.indexOf(getWeekStart()); }); export function formatHumanReadableDuration( duration: number, intervalFormat?: string, numberFormat?: string ): string { const dayJsDuration = dayjs.duration(duration, 's'); const hours = Math.floor(dayJsDuration.asHours()); const minutes = dayJsDuration.minutes(); const seconds = dayJsDuration.seconds(); switch (intervalFormat) { case 'decimal': return formatNumber(dayJsDuration.asHours(), numberFormat) + ' h'; case 'hours-minutes': return `${hours}h ${minutes.toString().padStart(2, '0')}min`; case 'hours-minutes-colon-separated': return `${hours}:${minutes.toString().padStart(2, '0')}`; case 'hours-minutes-seconds-colon-separated': return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; default: return `${hours}h ${minutes.toString().padStart(2, '0')}min`; } } /** * Format a duration for reporting views where cost and duration must reconcile. * * When the org's `hours-minutes` format is selected, seconds are normally dropped for * readability (e.g. "14h 45min"). In reports this can make the total duration appear * inconsistent with the billable cost (which is computed to the second). To keep the * two columns reconcilable without inflating column widths with "14h 45min 06s", * promote to the compact `HH:MM:SS` format in reporting contexts. */ export function formatReportingDuration( duration: number, intervalFormat?: string, numberFormat?: string ): string { const promoted = intervalFormat === 'hours-minutes' || intervalFormat === 'hours-minutes-colon-separated'; const effectiveFormat = promoted ? 'hours-minutes-seconds-colon-separated' : intervalFormat; return formatHumanReadableDuration(duration, effectiveFormat, numberFormat); } export function formatDuration(duration: number): string { const dayJsDuration = dayjs.duration(duration, 's'); const hours = Math.floor(dayJsDuration.asHours()); const minutes = dayJsDuration.minutes(); const seconds = dayJsDuration.seconds(); return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; } export function calculateDifference(start: string, end: string | null) { if (end === null) { end = dayjs().utc().format(); } return dayjs(end).diff(dayjs(start), 'second'); } /** * Returns a formatted time. * @param date - A UTC date time string. * @param timeFormat - The time format to use ('12-hours' or '24-hours') */ export function formatTime(date: string, timeFormat: TimeFormat = '24-hours') { const format = timeFormat === '12-hours' ? 'hh:mm A' : 'HH:mm'; return dayjs.utc(date).tz(getUserTimezone()).format(format); } export function getLocalizedDayJs(timestamp?: string | null) { return dayjs.utc(timestamp).tz(getUserTimezone()); } /** * Create a dayjs instance for a specific wall-clock time on a given day. * Sets hour/minute directly to avoid DST issues with .add(minutes) on * transition days. Negative or overflow values are normalised by shifting * whole days (`.add(n, 'day')` is DST-safe). */ export function getLocalizedDayJsFromMinutes(dayStr: string, minutesFromMidnight: number) { const dayOffset = Math.floor(minutesFromMidnight / (24 * 60)); const remainder = minutesFromMidnight - dayOffset * 24 * 60; return dayjs .tz(`${dayStr}T00:00:00`, getUserTimezone()) .add(dayOffset, 'day') .hour(Math.floor(remainder / 60)) .minute(Math.round(remainder % 60)) .second(0); } export function getLocalizedDateFromTimestamp(timestamp: string) { return getLocalizedDayJs(timestamp).format('YYYY-MM-DD'); } /** * Converts a local Date to a UTC-formatted ISO string. * Treats the Date as being in the user's timezone and converts to UTC. * This is the inverse of getLocalizedDayJs (which goes UTC → local). */ export function localDateToUtc(date: dayjs.Dayjs): string { return date.tz(getUserTimezone(), true).utc().format(); } /* * Returns a formatted date. * @param date - date in the format of 'YYYY-MM-DD' */ export function formatDate(date: string, format: DateFormat = 'point-separated-d-m-yyyy'): string { if (date?.includes('+')) { console.warn('Date contains timezone information, use formatDateLocalized instead'); } return getDayJsInstance()(date).format(dateFormatMap[format]); } /* * Returns a formatted date. * @param date - date in the format of 'YYYY-MM-DD' */ export function formatDateLocalized( date: string, format: DateFormat = 'point-separated-d-m-yyyy' ): string { return getLocalizedDayJs(date).format(dateFormatMap[format]); } export function formatDateTimeLocalized( date: string, dateFormat?: DateFormat, timeFormat?: TimeFormat ): string { const format = `${dateFormatMap[dateFormat ?? 'point-separated-d-m-yyyy']} ${timeFormat === '12-hours' ? 'hh:mm A' : 'HH:mm'}`; return getLocalizedDayJs(date).format(format); } export function formatWeek(date: string | null): string { return 'Week ' + getDayJsInstance()(date).week(); } /* * Returns a human readable date format. * @param date - date in the format of 'YYYY-MM-DD' */ export function formatHumanReadableDate(date: string) { const dateObj = dayjs(date); const today = dayjs(); if (dateObj.isToday()) { return 'Today'; } else if (dateObj.isYesterday()) { return 'Yesterday'; } // Calculate difference in days const diffInDays = today.diff(dateObj, 'day'); if (diffInDays > 0 && diffInDays <= 30) { // For dates in the past (2-30 days ago) return `${diffInDays} ${diffInDays === 1 ? 'day' : 'days'} ago`; } else if (diffInDays < 0 && diffInDays >= -30) { // For dates in the future (within 30 days) const futureDays = Math.abs(diffInDays); return `In ${futureDays} ${futureDays === 1 ? 'day' : 'days'}`; } // For dates older than 30 days, show the actual date return dateObj.format('MMM D, YYYY'); } export function formatWeekday(date: string) { return dayjs(date).format('dddd'); } export function formatStartEnd( start: string, end: string | null, timeFormat: TimeFormat = '24-hours' ) { if (end) { return `${formatTime(start, timeFormat)} - ${formatTime(end, timeFormat)}`; } else { return `${formatTime(start, timeFormat)} - ...`; } } export function parseTimeInput( input: string, numberFormat?: string, defaultUnit: 'minutes' | 'hours' = 'minutes' ): number | null { configureParseLocale(numberFormat); // Check if input is a decimal number (hours) const decimalRegex = /^-?\d+[.,]\d+$/; if (decimalRegex.test(input)) { const hours = parseFloat(input.replace(',', '.')); return Math.round(hours * 3600); } // Check if input is just a number if (/^-?\d+$/.test(input)) { const value = parseInt(input); return defaultUnit === 'hours' ? value * 3600 : value * 60; } // Check if input is in HH:MM:SS format const HHMMSStimeRegex = /^([0-9]{1,2}):([0-5]?[0-9]):([0-5]?[0-9])$/; if (HHMMSStimeRegex.test(input)) { const match = input.match(HHMMSStimeRegex); if (match) { const hours = parseInt(match[1]!); const minutes = parseInt(match[2]!); const seconds = parseInt(match[3]!); return hours * 3600 + minutes * 60 + seconds; } } // Check if input is in HH:MM format const HHMMtimeRegex = /^([0-9]{1,2}):([0-5]?[0-9])$/; if (HHMMtimeRegex.test(input)) { const match = input.match(HHMMtimeRegex); if (match) { const hours = parseInt(match[1]!); const minutes = parseInt(match[2]!); return (hours * 60 + minutes) * 60; } } // Try to parse natural language like "1h 30m" or locale-formatted like "1,00 h" const parsedDuration = parse(input, 's'); if (parsedDuration && parsedDuration > 0) { return parsedDuration; } return null; }