mirror of
https://github.com/iib0011/omni-tools.git
synced 2025-09-18 21:49:31 +02:00
@@ -1,3 +1,12 @@
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(duration);
|
||||
|
||||
export const unitHierarchy = [
|
||||
'years',
|
||||
'months',
|
||||
@@ -11,70 +20,153 @@ export const unitHierarchy = [
|
||||
export type TimeUnit = (typeof unitHierarchy)[number];
|
||||
export type TimeDifference = Record<TimeUnit, number>;
|
||||
|
||||
// Mapping common abbreviations to IANA time zone names
|
||||
export const tzMap: { [abbr: string]: string } = {
|
||||
EST: 'America/New_York',
|
||||
EDT: 'America/New_York',
|
||||
CST: 'America/Chicago',
|
||||
CDT: 'America/Chicago',
|
||||
MST: 'America/Denver',
|
||||
MDT: 'America/Denver',
|
||||
PST: 'America/Los_Angeles',
|
||||
PDT: 'America/Los_Angeles',
|
||||
GMT: 'Etc/GMT',
|
||||
UTC: 'Etc/UTC'
|
||||
// add more mappings as needed
|
||||
};
|
||||
|
||||
// Parse a date string with a time zone abbreviation,
|
||||
// e.g. "02/02/2024 14:55 EST"
|
||||
export const parseWithTZ = (dateTimeStr: string): dayjs.Dayjs => {
|
||||
const parts = dateTimeStr.trim().split(' ');
|
||||
const tzAbbr = parts.pop()!; // extract the timezone part (e.g., EST)
|
||||
const dateTimePart = parts.join(' ');
|
||||
const tzName = tzMap[tzAbbr];
|
||||
if (!tzName) {
|
||||
throw new Error(`Timezone abbreviation ${tzAbbr} not supported`);
|
||||
}
|
||||
// Parse using the format "MM/DD/YYYY HH:mm" in the given time zone
|
||||
return dayjs.tz(dateTimePart, 'MM/DD/YYYY HH:mm', tzName);
|
||||
};
|
||||
|
||||
export const calculateTimeBetweenDates = (
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): TimeDifference => {
|
||||
if (endDate < startDate) {
|
||||
const temp = startDate;
|
||||
startDate = endDate;
|
||||
endDate = temp;
|
||||
let start = dayjs(startDate);
|
||||
let end = dayjs(endDate);
|
||||
|
||||
// Swap dates if start is after end
|
||||
if (end.isBefore(start)) {
|
||||
[start, end] = [end, start];
|
||||
}
|
||||
|
||||
const milliseconds = endDate.getTime() - startDate.getTime();
|
||||
const seconds = Math.floor(milliseconds / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
// Calculate each unit incrementally so that the remainder is applied for subsequent units.
|
||||
const years = end.diff(start, 'year');
|
||||
const startPlusYears = start.add(years, 'year');
|
||||
|
||||
// Approximate months and years
|
||||
const startYear = startDate.getFullYear();
|
||||
const startMonth = startDate.getMonth();
|
||||
const endYear = endDate.getFullYear();
|
||||
const endMonth = endDate.getMonth();
|
||||
const months = end.diff(startPlusYears, 'month');
|
||||
const startPlusMonths = startPlusYears.add(months, 'month');
|
||||
|
||||
const months = (endYear - startYear) * 12 + (endMonth - startMonth);
|
||||
const years = Math.floor(months / 12);
|
||||
const days = end.diff(startPlusMonths, 'day');
|
||||
const startPlusDays = startPlusMonths.add(days, 'day');
|
||||
|
||||
const hours = end.diff(startPlusDays, 'hour');
|
||||
const startPlusHours = startPlusDays.add(hours, 'hour');
|
||||
|
||||
const minutes = end.diff(startPlusHours, 'minute');
|
||||
const startPlusMinutes = startPlusHours.add(minutes, 'minute');
|
||||
|
||||
const seconds = end.diff(startPlusMinutes, 'second');
|
||||
const startPlusSeconds = startPlusMinutes.add(seconds, 'second');
|
||||
|
||||
const milliseconds = end.diff(startPlusSeconds, 'millisecond');
|
||||
|
||||
return {
|
||||
milliseconds,
|
||||
seconds,
|
||||
minutes,
|
||||
hours,
|
||||
days,
|
||||
years,
|
||||
months,
|
||||
years
|
||||
days,
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
milliseconds
|
||||
};
|
||||
};
|
||||
|
||||
// Calculate duration between two date strings with timezone abbreviations
|
||||
export const getDuration = (
|
||||
startStr: string,
|
||||
endStr: string
|
||||
): TimeDifference => {
|
||||
const start = parseWithTZ(startStr);
|
||||
const end = parseWithTZ(endStr);
|
||||
|
||||
if (end.isBefore(start)) {
|
||||
throw new Error('End date must be after start date');
|
||||
}
|
||||
|
||||
return calculateTimeBetweenDates(start.toDate(), end.toDate());
|
||||
};
|
||||
|
||||
export const formatTimeDifference = (
|
||||
difference: TimeDifference,
|
||||
includeUnits: TimeUnit[] = unitHierarchy.slice(0, -1)
|
||||
includeUnits: TimeUnit[] = unitHierarchy.slice(0, -2)
|
||||
): string => {
|
||||
const timeUnits: { key: TimeUnit; value: number; divisor?: number }[] = [
|
||||
{ key: 'years', value: difference.years },
|
||||
{ key: 'months', value: difference.months, divisor: 12 },
|
||||
{ key: 'days', value: difference.days, divisor: 30 },
|
||||
{ key: 'hours', value: difference.hours, divisor: 24 },
|
||||
{ key: 'minutes', value: difference.minutes, divisor: 60 },
|
||||
{ key: 'seconds', value: difference.seconds, divisor: 60 }
|
||||
// First normalize the values (convert 24 hours to 1 day, etc.)
|
||||
const normalized = { ...difference };
|
||||
|
||||
// Convert milliseconds to seconds
|
||||
if (normalized.milliseconds >= 1000) {
|
||||
const additionalSeconds = Math.floor(normalized.milliseconds / 1000);
|
||||
normalized.seconds += additionalSeconds;
|
||||
normalized.milliseconds %= 1000;
|
||||
}
|
||||
|
||||
// Convert seconds to minutes
|
||||
if (normalized.seconds >= 60) {
|
||||
const additionalMinutes = Math.floor(normalized.seconds / 60);
|
||||
normalized.minutes += additionalMinutes;
|
||||
normalized.seconds %= 60;
|
||||
}
|
||||
|
||||
// Convert minutes to hours
|
||||
if (normalized.minutes >= 60) {
|
||||
const additionalHours = Math.floor(normalized.minutes / 60);
|
||||
normalized.hours += additionalHours;
|
||||
normalized.minutes %= 60;
|
||||
}
|
||||
|
||||
// Convert hours to days if 24 or more
|
||||
if (normalized.hours >= 24) {
|
||||
const additionalDays = Math.floor(normalized.hours / 24);
|
||||
normalized.days += additionalDays;
|
||||
normalized.hours %= 24;
|
||||
}
|
||||
|
||||
const timeUnits: { key: TimeUnit; value: number; label: string }[] = [
|
||||
{ key: 'years', value: normalized.years, label: 'year' },
|
||||
{ key: 'months', value: normalized.months, label: 'month' },
|
||||
{ key: 'days', value: normalized.days, label: 'day' },
|
||||
{ key: 'hours', value: normalized.hours, label: 'hour' },
|
||||
{ key: 'minutes', value: normalized.minutes, label: 'minute' },
|
||||
{ key: 'seconds', value: normalized.seconds, label: 'second' },
|
||||
{
|
||||
key: 'milliseconds',
|
||||
value: normalized.milliseconds,
|
||||
label: 'millisecond'
|
||||
}
|
||||
];
|
||||
|
||||
const parts = timeUnits
|
||||
.filter(({ key }) => includeUnits.includes(key))
|
||||
.map(({ key, value, divisor }) => {
|
||||
const remaining = divisor ? value % divisor : value;
|
||||
return remaining > 0 ? `${remaining} ${key}` : '';
|
||||
.map(({ value, label }) => {
|
||||
if (value === 0) return '';
|
||||
return `${value} ${label}${value === 1 ? '' : 's'}`;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (parts.length === 0) {
|
||||
if (includeUnits.includes('milliseconds')) {
|
||||
return `${difference.milliseconds} millisecond${
|
||||
difference.milliseconds === 1 ? '' : 's'
|
||||
}`;
|
||||
}
|
||||
return '0 seconds';
|
||||
return '0 minutes';
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
@@ -85,45 +177,49 @@ export const getTimeWithTimezone = (
|
||||
timeString: string,
|
||||
timezone: string
|
||||
): Date => {
|
||||
// Combine date and time
|
||||
const dateTimeString = `${dateString}T${timeString}Z`; // Append 'Z' to enforce UTC parsing
|
||||
const utcDate = new Date(dateTimeString);
|
||||
|
||||
if (isNaN(utcDate.getTime())) {
|
||||
throw new Error('Invalid date or time format');
|
||||
}
|
||||
|
||||
// If timezone is "local", return the local date
|
||||
if (timezone === 'local') {
|
||||
return utcDate;
|
||||
const dateTimeString = `${dateString}T${timeString}`;
|
||||
return dayjs(dateTimeString).toDate();
|
||||
}
|
||||
|
||||
// Extract offset from timezone (e.g., "GMT+5:30" or "GMT-4")
|
||||
// Check if the timezone is a known abbreviation
|
||||
if (tzMap[timezone]) {
|
||||
const dateTimeString = `${dateString} ${timeString}`;
|
||||
return dayjs
|
||||
.tz(dateTimeString, 'YYYY-MM-DD HH:mm', tzMap[timezone])
|
||||
.toDate();
|
||||
}
|
||||
|
||||
// Handle GMT+/- format
|
||||
const match = timezone.match(/^GMT(?:([+-]\d{1,2})(?::(\d{2}))?)?$/);
|
||||
if (!match) {
|
||||
throw new Error('Invalid timezone format');
|
||||
}
|
||||
|
||||
const dateTimeString = `${dateString}T${timeString}Z`;
|
||||
const utcDate = dayjs.utc(dateTimeString);
|
||||
|
||||
if (!utcDate.isValid()) {
|
||||
throw new Error('Invalid date or time format');
|
||||
}
|
||||
|
||||
const offsetHours = match[1] ? parseInt(match[1], 10) : 0;
|
||||
const offsetMinutes = match[2] ? parseInt(match[2], 10) : 0;
|
||||
|
||||
const totalOffsetMinutes =
|
||||
offsetHours * 60 + (offsetHours < 0 ? -offsetMinutes : offsetMinutes);
|
||||
|
||||
// Adjust the UTC date by the timezone offset
|
||||
return new Date(utcDate.getTime() - totalOffsetMinutes * 60 * 1000);
|
||||
return utcDate.subtract(totalOffsetMinutes, 'minute').toDate();
|
||||
};
|
||||
|
||||
// Helper function to format time based on largest unit
|
||||
export const formatTimeWithLargestUnit = (
|
||||
difference: TimeDifference,
|
||||
largestUnit: TimeUnit
|
||||
): string => {
|
||||
const largestUnitIndex = unitHierarchy.indexOf(largestUnit);
|
||||
const unitsToInclude = unitHierarchy.slice(largestUnitIndex);
|
||||
|
||||
// Preserve only whole values, do not apply fractional conversions
|
||||
const adjustedDifference: TimeDifference = { ...difference };
|
||||
|
||||
return formatTimeDifference(adjustedDifference, unitsToInclude);
|
||||
const unitsToInclude = unitHierarchy.slice(
|
||||
largestUnitIndex,
|
||||
unitHierarchy.length // Include milliseconds if it's the largest unit requested
|
||||
);
|
||||
return formatTimeDifference(difference, unitsToInclude);
|
||||
};
|
||||
|
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import {
|
||||
calculateTimeBetweenDates,
|
||||
formatTimeDifference,
|
||||
@@ -6,31 +10,78 @@ import {
|
||||
getTimeWithTimezone
|
||||
} from './service';
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
dayjs.extend(duration);
|
||||
|
||||
// Utility function to create a date
|
||||
const createDate = (
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
hours = 0,
|
||||
minutes = 0,
|
||||
seconds = 0
|
||||
) => new Date(Date.UTC(year, month - 1, day, hours, minutes, seconds));
|
||||
minutes = 0
|
||||
) => dayjs.utc(Date.UTC(year, month - 1, day, hours, minutes)).toDate();
|
||||
|
||||
describe('calculateTimeBetweenDates', () => {
|
||||
it('should calculate the correct time difference', () => {
|
||||
it('should calculate exactly 1 year difference', () => {
|
||||
const startDate = createDate(2023, 1, 1);
|
||||
const endDate = createDate(2024, 1, 1);
|
||||
const result = calculateTimeBetweenDates(startDate, endDate);
|
||||
|
||||
expect(result.years).toBe(1);
|
||||
expect(result.months).toBe(12);
|
||||
expect(result.days).toBeGreaterThanOrEqual(365);
|
||||
expect(result.months).toBe(0);
|
||||
expect(result.days).toBe(0);
|
||||
expect(result.hours).toBe(0);
|
||||
expect(result.minutes).toBe(0);
|
||||
});
|
||||
|
||||
it('should calculate 1 year and 1 day difference', () => {
|
||||
const startDate = createDate(2023, 1, 1);
|
||||
const endDate = createDate(2024, 1, 2);
|
||||
const result = calculateTimeBetweenDates(startDate, endDate);
|
||||
|
||||
expect(result.years).toBe(1);
|
||||
expect(result.months).toBe(0);
|
||||
expect(result.days).toBe(1);
|
||||
expect(result.hours).toBe(0);
|
||||
expect(result.minutes).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle leap year correctly', () => {
|
||||
const startDate = createDate(2024, 2, 28); // February 28th in leap year
|
||||
const endDate = createDate(2024, 3, 1); // March 1st
|
||||
const result = calculateTimeBetweenDates(startDate, endDate);
|
||||
|
||||
expect(result.days).toBe(2);
|
||||
expect(result.months).toBe(0);
|
||||
expect(result.years).toBe(0);
|
||||
expect(result.hours).toBe(0);
|
||||
expect(result.minutes).toBe(0);
|
||||
});
|
||||
|
||||
it('should swap dates if startDate is after endDate', () => {
|
||||
const startDate = createDate(2024, 1, 1);
|
||||
const endDate = createDate(2023, 1, 1);
|
||||
const result = calculateTimeBetweenDates(startDate, endDate);
|
||||
|
||||
expect(result.years).toBe(1);
|
||||
expect(result.months).toBe(0);
|
||||
expect(result.days).toBe(0);
|
||||
expect(result.hours).toBe(0);
|
||||
expect(result.minutes).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle same day different hours', () => {
|
||||
const startDate = createDate(2024, 1, 1, 10);
|
||||
const endDate = createDate(2024, 1, 1, 15);
|
||||
const result = calculateTimeBetweenDates(startDate, endDate);
|
||||
|
||||
expect(result.years).toBe(0);
|
||||
expect(result.months).toBe(0);
|
||||
expect(result.days).toBe(0);
|
||||
expect(result.hours).toBe(5);
|
||||
expect(result.minutes).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,11 +97,26 @@ describe('formatTimeDifference', () => {
|
||||
milliseconds: 0
|
||||
};
|
||||
expect(formatTimeDifference(difference)).toBe(
|
||||
'1 years, 2 months, 10 days, 5 hours, 30 minutes'
|
||||
'1 year, 2 months, 10 days, 5 hours, 30 minutes'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 0 seconds if all values are zero', () => {
|
||||
it('should handle singular units correctly', () => {
|
||||
const difference = {
|
||||
years: 1,
|
||||
months: 1,
|
||||
days: 1,
|
||||
hours: 1,
|
||||
minutes: 1,
|
||||
seconds: 0,
|
||||
milliseconds: 0
|
||||
};
|
||||
expect(formatTimeDifference(difference)).toBe(
|
||||
'1 year, 1 month, 1 day, 1 hour, 1 minute'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 0 minutes if all values are zero', () => {
|
||||
expect(
|
||||
formatTimeDifference({
|
||||
years: 0,
|
||||
@@ -61,7 +127,7 @@ describe('formatTimeDifference', () => {
|
||||
seconds: 0,
|
||||
milliseconds: 0
|
||||
})
|
||||
).toBe('0 seconds');
|
||||
).toBe('0 minutes');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,12 +151,12 @@ describe('formatTimeWithLargestUnit', () => {
|
||||
months: 1,
|
||||
days: 15,
|
||||
hours: 12,
|
||||
minutes: 0,
|
||||
minutes: 30,
|
||||
seconds: 0,
|
||||
milliseconds: 0
|
||||
};
|
||||
expect(formatTimeWithLargestUnit(difference, 'days')).toContain(
|
||||
'15 days, 12 hours'
|
||||
expect(formatTimeWithLargestUnit(difference, 'days')).toBe(
|
||||
'15 days, 12 hours, 30 minutes'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
Reference in New Issue
Block a user