Merge pull request #94 from TheLukasHenry/#84-fix-dates

#84 fix dates
This commit is contained in:
Ibrahima G. Coulibaly
2025-04-09 23:59:09 +01:00
committed by GitHub
4 changed files with 241 additions and 71 deletions

7
package-lock.json generated
View File

@@ -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",
@@ -4674,6 +4675,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",

View File

@@ -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",

View File

@@ -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);
};

View File

@@ -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'
);
});
});