From 92ce1184de397b58ede5cd4f9083f52ebbe145f0 Mon Sep 17 00:00:00 2001 From: Lukas Herajt Date: Tue, 8 Apr 2025 13:27:30 -0400 Subject: [PATCH] time working --- package-lock.json | 7 + package.json | 1 + .../tools/time/time-between-dates/service.ts | 214 +++++++++++++----- .../time-between-dates.service.test.ts | 90 +++++++- 4 files changed, 241 insertions(+), 71 deletions(-) diff --git a/package-lock.json b/package-lock.json index b2074f9..5dc1d58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@types/omggif": "^1.0.5", "browser-image-compression": "^2.0.2", "color": "^4.2.3", + "dayjs": "^1.11.13", "formik": "^2.4.6", "jimp": "^0.22.12", "lint-staged": "^15.4.3", @@ -4673,6 +4674,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", diff --git a/package.json b/package.json index 68dfa2d..7477f0d 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@types/omggif": "^1.0.5", "browser-image-compression": "^2.0.2", "color": "^4.2.3", + "dayjs": "^1.11.13", "formik": "^2.4.6", "jimp": "^0.22.12", "lint-staged": "^15.4.3", diff --git a/src/pages/tools/time/time-between-dates/service.ts b/src/pages/tools/time/time-between-dates/service.ts index 785f392..70b247b 100644 --- a/src/pages/tools/time/time-between-dates/service.ts +++ b/src/pages/tools/time/time-between-dates/service.ts @@ -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; +// 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); }; diff --git a/src/pages/tools/time/time-between-dates/time-between-dates.service.test.ts b/src/pages/tools/time/time-between-dates/time-between-dates.service.test.ts index 5a8db7f..8c1dbc1 100644 --- a/src/pages/tools/time/time-between-dates/time-between-dates.service.test.ts +++ b/src/pages/tools/time/time-between-dates/time-between-dates.service.test.ts @@ -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' ); }); });