diff --git a/components.d.ts b/components.d.ts index ea73a54a6..6ed10973a 100644 --- a/components.d.ts +++ b/components.d.ts @@ -64,7 +64,9 @@ declare module '@vue/runtime-core' { 'CTextCopyable.demo': typeof import('./src/ui/c-text-copyable/c-text-copyable.demo.vue')['default'] CTooltip: typeof import('./src/ui/c-tooltip/c-tooltip.vue')['default'] 'CTooltip.demo': typeof import('./src/ui/c-tooltip/c-tooltip.demo.vue')['default'] + DateDurationCalculator: typeof import('./src/tools/date-duration-calculator/date-duration-calculator.vue')['default'] DateTimeConverter: typeof import('./src/tools/date-time-converter/date-time-converter.vue')['default'] + DaysCalculator: typeof import('./src/tools/days-calculator/days-calculator.vue')['default'] 'DemoHome.page': typeof import('./src/ui/demo/demo-home.page.vue')['default'] DemoWrapper: typeof import('./src/ui/demo/demo-wrapper.vue')['default'] DeviceInformation: typeof import('./src/tools/device-information/device-information.vue')['default'] @@ -131,22 +133,25 @@ declare module '@vue/runtime-core' { MetaTagGenerator: typeof import('./src/tools/meta-tag-generator/meta-tag-generator.vue')['default'] MimeTypes: typeof import('./src/tools/mime-types/mime-types.vue')['default'] NavbarButtons: typeof import('./src/components/NavbarButtons.vue')['default'] + NCheckbox: typeof import('naive-ui')['NCheckbox'] + NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup'] NCode: typeof import('naive-ui')['NCode'] NCollapseTransition: typeof import('naive-ui')['NCollapseTransition'] NConfigProvider: typeof import('naive-ui')['NConfigProvider'] + NDatePicker: typeof import('naive-ui')['NDatePicker'] NDivider: typeof import('naive-ui')['NDivider'] NEllipsis: typeof import('naive-ui')['NEllipsis'] - NGi: typeof import('naive-ui')['NGi'] - NGrid: typeof import('naive-ui')['NGrid'] + NFormItem: typeof import('naive-ui')['NFormItem'] NH1: typeof import('naive-ui')['NH1'] NH3: typeof import('naive-ui')['NH3'] NIcon: typeof import('naive-ui')['NIcon'] + NInputNumber: typeof import('naive-ui')['NInputNumber'] NLayout: typeof import('naive-ui')['NLayout'] NLayoutSider: typeof import('naive-ui')['NLayoutSider'] NMenu: typeof import('naive-ui')['NMenu'] NP: typeof import('naive-ui')['NP'] NScrollbar: typeof import('naive-ui')['NScrollbar'] - NTag: typeof import('naive-ui')['NTag'] + NSpace: typeof import('naive-ui')['NSpace'] NumeronymGenerator: typeof import('./src/tools/numeronym-generator/numeronym-generator.vue')['default'] OtpCodeGeneratorAndValidator: typeof import('./src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue')['default'] PasswordStrengthAnalyser: typeof import('./src/tools/password-strength-analyser/password-strength-analyser.vue')['default'] diff --git a/package.json b/package.json index e7e58d7a3..0ed265dc4 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@tiptap/starter-kit": "2.1.6", "@tiptap/vue-3": "2.0.3", "@types/figlet": "^1.5.8", + "@types/luxon": "^3.4.2", "@types/markdown-it": "^13.0.7", "@vicons/material": "^0.12.0", "@vicons/tabler": "^0.12.0", @@ -54,11 +55,13 @@ "change-case": "^4.1.2", "colord": "^2.9.3", "composerize-ts": "^0.6.2", + "countries-and-timezones": "^3.7.2", "country-code-lookup": "^0.1.0", "cron-validator": "^1.3.1", "cronstrue": "^2.26.0", "crypto-js": "^4.1.1", "date-fns": "^2.29.3", + "date-holidays": "^3.23.12", "dompurify": "^3.0.6", "duration-fns": "^3.0.2", "email-normalizer": "^1.0.0", @@ -74,6 +77,7 @@ "jwt-decode": "^3.1.2", "libphonenumber-js": "^1.10.28", "lodash": "^4.17.21", + "luxon": "^3.5.0", "markdown-it": "^14.0.0", "marked": "^10.0.0", "mathjs": "^11.9.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e078fa5a0..57e9be3f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ dependencies: '@types/figlet': specifier: ^1.5.8 version: 1.5.8 + '@types/luxon': + specifier: ^3.4.2 + version: 3.4.2 '@types/markdown-it': specifier: ^13.0.7 version: 13.0.9 @@ -59,6 +62,9 @@ dependencies: composerize-ts: specifier: ^0.6.2 version: 0.6.2 + countries-and-timezones: + specifier: ^3.7.2 + version: 3.7.2 country-code-lookup: specifier: ^0.1.0 version: 0.1.0 @@ -74,6 +80,9 @@ dependencies: date-fns: specifier: ^2.29.3 version: 2.29.3 + date-holidays: + specifier: ^3.23.12 + version: 3.23.12 dompurify: specifier: ^3.0.6 version: 3.0.6 @@ -119,6 +128,9 @@ dependencies: lodash: specifier: ^4.17.21 version: 4.17.21 + luxon: + specifier: ^3.5.0 + version: 3.5.0 markdown-it: specifier: ^14.0.0 version: 14.1.0 @@ -3005,6 +3017,10 @@ packages: resolution: {integrity: sha512-YI/M/4HRImtNf3pJgbF+W6FrXovqj+T+/HpENLTooK9PnkacBsDpeP3IpHab40CClUfhNmdM2WTNP2sa2dni5Q==} dev: false + /@types/luxon@3.4.2: + resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} + dev: false + /@types/markdown-it@12.2.3: resolution: {integrity: sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==} dependencies: @@ -4192,6 +4208,11 @@ packages: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} dev: true + /astronomia@4.1.1: + resolution: {integrity: sha512-TcJD9lUC5eAo0/Ji7rnQauX/yQbi0yZWM+JsNr77W3OA5fsrgvuFgubLMFwfw4VlZ29cu9dG/yfJbfvuTSftjg==} + engines: {node: '>=12.0.0'} + dev: false + /async-validator@4.2.5: resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} dev: false @@ -4350,6 +4371,13 @@ packages: engines: {node: '>=8'} dev: true + /caldate@2.0.5: + resolution: {integrity: sha512-JndhrUuDuE975KUhFqJaVR1OQkCHZqpOrJur/CFXEIEhWhBMjxO85cRSK8q4FW+B+yyPq6GYua2u4KvNzTcq0w==} + engines: {node: '>=12.0.0'} + dependencies: + moment-timezone: 0.5.46 + dev: false + /call-bind@1.0.5: resolution: {integrity: sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==} dependencies: @@ -4666,6 +4694,11 @@ packages: browserslist: 4.22.1 dev: true + /countries-and-timezones@3.7.2: + resolution: {integrity: sha512-BHAMt4pKb3U3r/mRfiIlVnDhRd8m6VC20gwCWtpZGZkSsjZmnMDKFnnjWYGWhBmypQAqcQILFJwmEhIgWGVTmw==} + engines: {node: '>=8.x', npm: '>=5.x'} + dev: false + /country-code-lookup@0.1.0: resolution: {integrity: sha512-IOI66HEG+8bXfWPy+sTzuN7161vmDZOHg1wgIPFf3WfD73FeLajnn6C+fnxOIa9RL1WRBDMXQQWW/FOaOYaQ3w==} dev: false @@ -4783,6 +4816,23 @@ packages: whatwg-url: 12.0.1 dev: true + /date-bengali-revised@2.0.2: + resolution: {integrity: sha512-q9iDru4+TSA9k4zfm0CFHJj6nBsxP7AYgWC/qodK/i7oOIlj5K2z5IcQDtESfs/Qwqt/xJYaP86tkazd/vRptg==} + engines: {node: '>=12.0.0'} + dev: false + + /date-chinese@2.1.4: + resolution: {integrity: sha512-WY+6+Qw92ZGWFvGtStmNQHEYpNa87b8IAQ5T8VKt4wqrn24lBXyyBnWI5jAIyy7h/KVwJZ06bD8l/b7yss82Ww==} + engines: {node: '>=12.0.0'} + dependencies: + astronomia: 4.1.1 + dev: false + + /date-easter@1.0.3: + resolution: {integrity: sha512-aOViyIgpM4W0OWUiLqivznwTtuMlD/rdUWhc5IatYnplhPiWrLv75cnifaKYhmQwUBLAMWLNG4/9mlLIbXoGBQ==} + engines: {node: '>=12.0.0'} + dev: false + /date-fns-tz@2.0.0(date-fns@2.30.0): resolution: {integrity: sha512-OAtcLdB9vxSXTWHdT8b398ARImVwQMyjfYGkKD2zaGpHseG2UPHbHjXELReErZFxWdSLph3c2zOaaTyHfOhERQ==} peerDependencies: @@ -4803,6 +4853,31 @@ packages: '@babel/runtime': 7.23.2 dev: false + /date-holidays-parser@3.4.4: + resolution: {integrity: sha512-R5aO4oT8H51ZKdvApqHrqYEiNBrqT6tRj2PFXNcZfqMI4nxY7KKKly0ZsmquR5gY+x9ldKR8SAMdozzIInaoXg==} + engines: {node: '>=12.0.0'} + dependencies: + astronomia: 4.1.1 + caldate: 2.0.5 + date-bengali-revised: 2.0.2 + date-chinese: 2.1.4 + date-easter: 1.0.3 + deepmerge: 4.3.1 + jalaali-js: 1.2.7 + moment-timezone: 0.5.46 + dev: false + + /date-holidays@3.23.12: + resolution: {integrity: sha512-DLyP0PPVgNydgaTAY7SBS26+5h3KO1Z8FRKiAROkz0hAGNBLGAM48SMabfVa2ACRHH7Qw3LXYvlJkt9oa9WePA==} + engines: {node: '>=12.0.0'} + hasBin: true + dependencies: + date-holidays-parser: 3.4.4 + js-yaml: 4.1.0 + lodash: 4.17.21 + prepin: 1.0.3 + dev: false + /de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} dev: false @@ -6521,6 +6596,10 @@ packages: minimatch: 3.1.2 dev: true + /jalaali-js@1.2.7: + resolution: {integrity: sha512-gE+YHWSbygYAoJa+Xg8LWxGILqFOxZSBQQw39ghel01fVFUxV7bjL0x1JFsHcLQ3uPjvn81HQMa+kxwyPWnxGQ==} + dev: false + /javascript-natural-sort@0.7.1: resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} dev: false @@ -6573,7 +6652,6 @@ packages: hasBin: true dependencies: argparse: 2.0.1 - dev: true /jsbn@1.1.0: resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} @@ -6830,6 +6908,11 @@ packages: dependencies: yallist: 4.0.0 + /luxon@3.5.0: + resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} + engines: {node: '>=12'} + dev: false + /magic-string@0.25.9: resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} dependencies: @@ -7036,6 +7119,16 @@ packages: pkg-types: 1.0.3 ufo: 1.3.1 + /moment-timezone@0.5.46: + resolution: {integrity: sha512-ZXm9b36esbe7OmdABqIWJuBBiLLwAjrN7CE+7sYdCCx82Nabt1wHDj8TVseS59QIlfFPbOoiBPm6ca9BioG4hw==} + dependencies: + moment: 2.30.1 + dev: false + + /moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + dev: false + /monaco-editor@0.43.0: resolution: {integrity: sha512-cnoqwQi/9fml2Szamv1XbSJieGJ1Dc8tENVMD26Kcfl7xGQWp7OBKMjlwKVGYFJ3/AXJjSOGvcqK7Ry/j9BM1Q==} dev: false @@ -7570,6 +7663,11 @@ packages: engines: {node: '>= 0.8.0'} dev: true + /prepin@1.0.3: + resolution: {integrity: sha512-0XL2hreherEEvUy0fiaGEfN/ioXFV+JpImqIzQjxk6iBg4jQ2ARKqvC4+BmRD8w/pnpD+lbxvh0Ub+z7yBEjvA==} + hasBin: true + dev: false + /prettier@3.0.0: resolution: {integrity: sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g==} engines: {node: '>=14'} diff --git a/src/tools/date-duration-calculator/date-duration-calculator.service.test.ts b/src/tools/date-duration-calculator/date-duration-calculator.service.test.ts new file mode 100644 index 000000000..109531174 --- /dev/null +++ b/src/tools/date-duration-calculator/date-duration-calculator.service.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { addToDate } from './date-duration-calculator.service'; + +describe('date-duration-calculator', () => { + describe('addToDate', () => { + it('compute right values', () => { + expect(addToDate(new Date('2024-08-15T07:21:46Z'), '+1d 1m 20s')).to.deep.eq( + { + date: new Date('2024-08-16T07:23:06.000Z'), + durationPretty: '1d 1m 20s', + durationSeconds: 86480, + errors: [], + }, + ); + }); + }); +}); diff --git a/src/tools/date-duration-calculator/date-duration-calculator.service.ts b/src/tools/date-duration-calculator/date-duration-calculator.service.ts new file mode 100644 index 000000000..03898642b --- /dev/null +++ b/src/tools/date-duration-calculator/date-duration-calculator.service.ts @@ -0,0 +1,12 @@ +import { computeDuration } from '../duration-calculator/duration-calculator.service'; + +export function addToDate(date: Date, durations: string) { + const { total, errors } = computeDuration(durations); + + return { + errors, + date: new Date(date.getTime() + total.milliseconds), + durationSeconds: total.seconds, + durationPretty: total.prettified, + }; +} diff --git a/src/tools/date-duration-calculator/date-duration-calculator.vue b/src/tools/date-duration-calculator/date-duration-calculator.vue new file mode 100644 index 000000000..28091bff7 --- /dev/null +++ b/src/tools/date-duration-calculator/date-duration-calculator.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/tools/date-duration-calculator/index.ts b/src/tools/date-duration-calculator/index.ts new file mode 100644 index 000000000..328ca0fd3 --- /dev/null +++ b/src/tools/date-duration-calculator/index.ts @@ -0,0 +1,12 @@ +import { Calendar } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'Date+Durations Calculator', + path: '/date-duration-calculator', + description: 'Add/substract durations from a specific date', + keywords: ['date', 'duration', 'addition', 'calculator'], + component: () => import('./date-duration-calculator.vue'), + icon: Calendar, + createdAt: new Date('2024-08-15'), +}); diff --git a/src/tools/days-calculator/business-time-calculator.test.ts b/src/tools/days-calculator/business-time-calculator.test.ts new file mode 100644 index 000000000..7977a8648 --- /dev/null +++ b/src/tools/days-calculator/business-time-calculator.test.ts @@ -0,0 +1,611 @@ +import { DateTime } from 'luxon'; +import { describe, expect, it } from 'vitest'; +import type { DayOfWeek, Holiday } from './business-time-calculator'; +import { BusinessTime } from './business-time-calculator'; + +const weekDays: DayOfWeek[] = [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', +]; + +const allDays: DayOfWeek[] = [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', +]; + +interface TestCase { + businessTimezone: string + businessDays: DayOfWeek[] + businessHours: number[] + holidays: Holiday[] + start?: string + end?: string + expected: any +} + +type BusinessTimeMethod = keyof InstanceType; + +function testEachComputeTime(testCases: TestCase[], + businessTimeFunctionName: BusinessTimeMethod) { + for (const { + start, + end, + businessTimezone, + businessHours, + businessDays, + holidays, + expected, + } of testCases) { + if (!start || !end) { + throw new Error('Start and end dates must be defined'); + } + + const startDatetime = DateTime.fromISO(start) as DateTime; + if (!startDatetime.isValid) { + throw new Error(`Invalid start datetime: ${start}`); + } + const endDatetime = DateTime.fromISO(end) as DateTime; + if (!endDatetime.isValid) { + throw new Error(`Invalid end datetime: ${end}`); + } + const businessTime = new BusinessTime({ + businessTimezone, + businessHours, + businessDays, + holidays, + }); + + expect( + businessTime[businessTimeFunctionName]({ + start: startDatetime, + end: endDatetime, + } as never)).to.deep.eq(expected); + } +} + +function testEachMoveDateInBusinessTime(testCases: (TestCase & { datetime: string; moveBehind: boolean })[]) { + for (const { + businessTimezone, + businessHours, + businessDays, + holidays, + datetime, + moveBehind, + expected, + } of testCases) { + const businessTime = new BusinessTime({ + businessTimezone, + businessHours, + businessDays, + holidays, + }); + + expect( + businessTime + ._moveDateInBusinessTime({ + datetime: DateTime.fromISO(datetime), + moveBehind, + }) + .toISO()).to.deep.eq( + expected, + ); + } +} + +function testEachIsBusinessDay(testCases: (TestCase & { datetime: string })[]) { + for (const { + datetime, + businessTimezone, + businessHours, + businessDays, + holidays, + expected, + } of testCases) { + const datetimeObj = DateTime.fromISO(datetime) as DateTime; + if (!datetimeObj.isValid) { + throw new Error(`Invalid datetime: ${datetime}`); + } + const businessTime = new BusinessTime({ + businessTimezone, + businessHours, + businessDays, + holidays, + }); + + expect(businessTime.isBusinessDay(datetimeObj)).to.deep.eq(expected); + } +} + +function testEachAddBusinessSecondsToDate(testCases: (TestCase & { datetime: string; seconds: number })[]) { + for (const { + seconds, + businessTimezone, + businessHours, + businessDays, + holidays, + datetime, + expected, + } of testCases) { + const datetimeObj = DateTime.fromISO(datetime) as DateTime; + if (!datetimeObj.isValid) { + throw new Error(`Invalid datetime: ${datetime}`); + } + const businessTime = new BusinessTime({ + businessTimezone, + businessHours, + businessDays, + holidays, + }); + + expect( + businessTime + .addBusinessSecondsToDate({ datetime: datetimeObj, seconds }) + .toISO()).to.deep.eq( + expected, + ); + } +} + +function testEachRemoveBusinessSecondsToDate(testCases: (TestCase & { datetime: string; seconds: number })[]) { + for (const { + seconds, + businessTimezone, + businessHours, + businessDays, + holidays, + datetime, + expected, + } of testCases) { + const datetimeObj = DateTime.fromISO(datetime) as DateTime; + if (!datetimeObj.isValid) { + throw new Error(`Invalid datetime: ${datetime}`); + } + const businessTime = new BusinessTime({ + businessTimezone, + businessHours, + businessDays, + holidays, + }); + + expect( + businessTime + .removeBusinessSecondsFromDate({ datetime: datetimeObj, seconds }) + .toISO()).to.deep.eq( + expected, + ); + } +} + +describe('BusinessTime', () => { + it('compute business days', () => { + testEachComputeTime( + [ + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + start: '2020-12-28T09:00:00.000+01:00', + end: '2020-12-29T23:00:00.000+01:00', + expected: 2, + }, + ], + 'computeBusinessDaysInInterval', + ); + }); + + it('compute business hours', () => { + testEachComputeTime( + [ + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + start: '2020-12-28T13:45:00.000+01:00', + end: '2020-12-28T14:00:00.000+01:00', + expected: 0.25, + }, // same hour + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: ['25/12', '26/12'], + start: '2020-12-25T10:45:00.000+01:00', + end: '2020-12-27T10:00:00.000+01:00', + expected: 0, + }, // holidays days + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: ['25/12/2020', '26/12/2020'], + start: '2020-12-25T10:45:00.000+01:00', + end: '2020-12-27T10:00:00.000+01:00', + expected: 0, + }, // holidays days and dates + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: ['25/12/2022', '26/12/2022'], + start: '2020-12-25T10:45:00.000+01:00', + end: '2020-12-27T10:00:00.000+01:00', + expected: 8.25, + }, // holidays days and dates (wrong year) + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + start: '2020-12-28T14:00:00.000+01:00', + end: '2020-12-28T18:30:00.000+01:00', + expected: 4.5, + }, // same day + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + start: '2020-12-18T14:00:00.000+01:00', + end: '2020-12-21T14:30:00.000+01:00', + expected: 9.5, + }, // cross weekend + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + start: '2020-12-28T15:00:00.000+01:00', + end: '2020-12-28T20:00:00.000+01:00', + expected: 4, + }, // 4 hours in Rome + { + businessTimezone: 'America/Los_Angeles', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + start: '2020-12-28T15:00:00.000+01:00', + end: '2020-12-28T20:00:00.000+01:00', + expected: 1, + }, // 1 hour in San Francisco + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + start: '2021-01-04T10:00:00.000+01:00', + end: '2021-03-01T10:00:00.000+01:00', + expected: 360, + }, // 8 weeks, 45 hours / week => 360 + ], + 'computeBusinessHoursInInterval', + ); + }); + + it('compute business minutes', () => { + testEachComputeTime( + [ + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + start: '2020-12-28T13:45:00.000+01:00', + end: '2020-12-28T14:00:00.000+01:00', + expected: 15, + }, + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: ['01/01'], + start: '2020-12-31T13:45:00.000+01:00', + end: '2021-01-04T19:00:00.000+01:00', + expected: 855, + }, + ], + 'computeBusinessMinutesInInterval', + ); + }); + + it('compute business seconds', () => { + testEachComputeTime( + [ + { + businessTimezone: 'America/Los_Angeles', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + start: '2020-12-28T15:00:00.000+01:00', + end: '2020-12-28T20:00:00.000+01:00', + expected: 3600, + }, // 1 hour in San Francisco + ], + 'computeBusinessSecondsInInterval', + ); + }); + + it('compute isBusinessDay', () => { + testEachIsBusinessDay([ + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday'], + businessHours: [10, 19], + holidays: ['25/12', '26/12'], + datetime: '2020-12-28T14:00:00.000+01:00', + expected: true, + }, // monday + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday', 'friday'], + businessHours: [10, 19], + holidays: ['26/12'], + datetime: '2020-12-25T14:00:00.000+01:00', + expected: true, + }, // Christmas 2020 (friday) configured as business day + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday'], + holidays: ['25/12', '26/12'], + businessHours: [10, 19], + datetime: '2020-12-27T14:00:00.000+01:00', + expected: false, + }, // tuesday configured as rest day + { + businessTimezone: 'America/Los_Angeles', + businessDays: ['monday'], + businessHours: [10, 19], + holidays: ['25/12', '26/12'], + datetime: '2020-12-28T01:00:00.000+01:00', + expected: false, + }, // monday in Rome, sunday in San Francisco + ]); + }); + + it('compute moveDateInBusinessTime', () => { + testEachMoveDateInBusinessTime([ + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday'], + businessHours: [13, 15], + holidays: [], + moveBehind: false, + datetime: '2020-12-28T11:00:00.000+01:00', + expected: '2020-12-28T13:00:00.000+01:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday'], + businessHours: [13, 15], + holidays: [], + moveBehind: false, + datetime: '2020-12-28T14:00:00.000+01:00', + expected: '2020-12-28T14:00:00.000+01:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday'], + businessHours: [13, 15], + holidays: ['01/01'], + moveBehind: false, + datetime: '2020-12-28T16:00:00.000+01:00', + expected: '2021-01-04T13:00:00.000+01:00', + }, + { + businessTimezone: 'America/Los_Angeles', + businessDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], + businessHours: [10, 19], + holidays: [], + moveBehind: false, + datetime: '2021-06-15T00:00:00.000+02:00', // tuesday + expected: '2021-06-14T15:00:00.000-07:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday', 'tuesday'], + businessHours: [13, 15], + holidays: [], + moveBehind: true, + datetime: '2022-04-12T11:00:00.000+02:00', + expected: '2022-04-11T15:00:00.000+02:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday'], + businessHours: [13, 15], + holidays: [], + moveBehind: true, + datetime: '2020-12-28T14:00:00.000+01:00', + expected: '2020-12-28T14:00:00.000+01:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday'], + businessHours: [13, 15], + holidays: ['01/01'], + moveBehind: true, + datetime: '2022-04-11T11:00:00.000+02:00', + expected: '2022-04-04T15:00:00.000+02:00', + }, + { + businessTimezone: 'America/Los_Angeles', + businessDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], + businessHours: [10, 19], + holidays: [], + moveBehind: true, + datetime: '2021-06-15T00:00:00.000+02:00', // tuesday + expected: '2021-06-14T15:00:00.000-07:00', + }, + ]); + }); + + it('add business seconds to date', () => { + testEachAddBusinessSecondsToDate([ + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + datetime: '2020-12-28T10:45:00.000+01:00', + seconds: 3600 * 10, + expected: '2020-12-29T10:45:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + datetime: '2022-04-04T19:45:00.000+02:00', + seconds: 3600 * 10, + expected: '2022-04-06T09:00:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: allDays, + businessHours: [0, 24], + holidays: ['01/01'], + datetime: '2020-12-28T10:45:00.000+01:00', + seconds: 3600 * 96, + expected: '2021-01-02T09:45:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: allDays, + businessHours: [0, 24], + holidays: [], + datetime: '2020-12-28T10:45:00.000+01:00', + seconds: 3600 * 96, + expected: '2021-01-01T09:45:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: allDays, + businessHours: [0, 12], + holidays: [], + datetime: '2020-12-28T10:45:00.000+01:00', + seconds: 3600 * 24, + expected: '2020-12-30T09:45:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: allDays, + businessHours: [0, 12], + holidays: [], + datetime: '2022-04-11T18:00:00.000+02:00', + seconds: 3600 * 24, + expected: '2022-04-13T10:00:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: allDays, + businessHours: [12, 24], + holidays: [], + datetime: '2022-04-04T10:00:00.000+02:00', + seconds: 3600 * 24, + expected: '2022-04-05T22:00:00.000+00:00', + }, + ]); + }); + + it('remove business seconds from date', () => { + testEachRemoveBusinessSecondsToDate([ + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], + businessHours: [10, 19], + holidays: [], + datetime: '2020-12-28T10:45:00.000+01:00', + seconds: 3600 * 10, + expected: '2020-12-24T17:45:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], + businessHours: [10, 19], + holidays: [], + datetime: '2022-04-08T19:45:00.000+02:00', + seconds: 3600 * 10, + expected: '2022-04-07T16:00:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: allDays, + businessHours: [0, 24], + holidays: ['25/12'], + datetime: '2020-12-28T10:11:11.111+01:00', + seconds: 3600 * 96, // 4 days + expected: '2020-12-23T09:11:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], // handle weekend + businessHours: [0, 24], + holidays: [], + datetime: '2022-04-11T12:00:00.000+02:00', + seconds: 3600 * 48, // 2 days + expected: '2022-04-07T10:00:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], // handle weekend + businessHours: [1, 24], + holidays: [], + datetime: '2022-04-11T12:00:00.000+02:00', + seconds: 3600 * 48, // 2 days + expected: '2022-04-07T08:00:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: allDays, + businessHours: [0, 12], + holidays: [], + datetime: '2022-04-08T10:45:00.000+02:00', + seconds: 3600 * 24, + expected: '2022-04-06T08:45:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: allDays, + businessHours: [0, 12], + holidays: [], + datetime: '2022-04-08T18:00:00.000+02:00', + seconds: 3600 * 24, + expected: '2022-04-06T22:00:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: allDays, + businessHours: [12, 24], + holidays: [], + datetime: '2022-04-08T10:00:00.000+02:00', + seconds: 3600 * 24, + expected: '2022-04-06T10:00:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], // handle weekend + businessHours: [12, 24], + holidays: [], + datetime: '2022-04-11T10:00:00.000+02:00', + seconds: 3600 * 24, + expected: '2022-04-07T10:00:00.000+00:00', + }, + ]); + }); + + it('compute working hours', () => { + expect(BusinessTime.computeWorkingHours(10, 19)).toBe(9); + expect(BusinessTime.computeWorkingHours(0, 24)).toBe(24); + expect(BusinessTime.computeWorkingHours(18, 3)).toBe(9); + expect(BusinessTime.computeWorkingHours(22, 0)).toBe(2); + }); +}); diff --git a/src/tools/days-calculator/business-time-calculator.ts b/src/tools/days-calculator/business-time-calculator.ts new file mode 100644 index 000000000..e6e8f2911 --- /dev/null +++ b/src/tools/days-calculator/business-time-calculator.ts @@ -0,0 +1,341 @@ +import type { DateTime } from 'luxon'; + +export type DayOfWeek = + | 'monday' + | 'tuesday' + | 'wednesday' + | 'thursday' + | 'friday' + | 'saturday' + | 'sunday'; + +const weekDayToName = { + 1: 'monday', + 2: 'tuesday', + 3: 'wednesday', + 4: 'thursday', + 5: 'friday', + 6: 'saturday', + 7: 'sunday', +}; + +export type Holiday = `${3 | 2 | 1 | 0}${number}/${1 | 0}${number}` | `${3 | 2 | 1 | 0}${number}/${1 | 0}${number}/${number}${number}${number}${number}`; + +export class BusinessTime { + private readonly businessTimezone: string; + + private readonly businessDays: DayOfWeek[]; + private readonly holidays: Holiday[]; + private readonly startOfDayTime: { hour: number; minute: number; second: number }; + private readonly endOfDayTime: { hour: number; minute: number; second: number }; + + static readonly computeWorkingHours = (startHour: number, endHour: number) => { + if (endHour < startHour) { + const workingHours = Math.abs(Math.abs(startHour - 24) + endHour); + return workingHours; + } + + const workingHours = endHour - startHour; + return workingHours; + }; + + constructor({ + businessTimezone, + businessDays, + businessHours, + holidays, + }: { + businessTimezone: string + businessDays: DayOfWeek[] + businessHours: number[] + holidays: Holiday[] + }) { + this.businessTimezone = businessTimezone; + this.businessDays = businessDays; + this.holidays = holidays; + this.startOfDayTime = { + hour: businessHours[0], + minute: 0, + second: 0, + }; + this.endOfDayTime = { + hour: businessHours[1], + minute: 0, + second: 0, + }; + } + + computeWorkingHours = () => { + const workingHours = BusinessTime.computeWorkingHours( + this.startOfDayTime.hour, + this.endOfDayTime.hour, + ); + return workingHours; + }; + + isBusinessDay(datetime: DateTime) { + const date = datetime.setZone(this.businessTimezone); + if (!date.isValid) { + throw new Error('Invalid date'); + } + + const dayMonth = date.toFormat('dd/MM') as Holiday; + const dayMonthYear = date.toFormat('dd/MM/yyyy') as Holiday; + if (this.holidays.includes(dayMonth) || this.holidays.includes(dayMonthYear)) { + return false; + } + + if (this.businessDays.includes(weekDayToName[date.weekday] as DayOfWeek)) { + return true; + } + + return false; + } + + computeBusinessDaysInInterval({ + start, + end, + }: { + start: DateTime + end: DateTime + }) { + const businessHours = this.computeBusinessHoursInInterval({ start, end }); + const workingHours = this.computeWorkingHours(); + return businessHours / workingHours; + } + + computeBusinessHoursInInterval({ + start, + end, + }: { + start: DateTime + end: DateTime + }) { + return this.computeBusinessTimeInInterval({ start, end, unit: 'hours' }); + } + + computeBusinessMinutesInInterval({ + start, + end, + }: { + start: DateTime + end: DateTime + }) { + return this.computeBusinessTimeInInterval({ start, end, unit: 'minutes' }); + } + + computeBusinessSecondsInInterval({ + start, + end, + }: { + start: DateTime + end: DateTime + }) { + return this.computeBusinessTimeInInterval({ start, end, unit: 'seconds' }); + } + + computeBusinessTimeInInterval({ + start, + end, + unit, + }: { + start: DateTime + end: DateTime + unit: 'hours' | 'minutes' | 'seconds' + }) { + if (start > end) { + throw new Error('start date is greater than end date'); + } + + const interval = { + start: this._moveDateInBusinessTime({ datetime: start }), + end: this._moveDateInBusinessTime({ datetime: end }), + }; + + let datetime = interval.start; + let businessTime = 0; + + while (datetime < interval.end) { + if (!this.isBusinessDay(datetime)) { + datetime = datetime.plus({ days: 1 }).set(this.startOfDayTime); + continue; + } + + if (datetime.toISODate() === interval.end.toISODate()) { + businessTime += interval.end.diff(datetime).as(unit); + datetime = interval.end; + } + else { + const endOfBusinessDay = datetime.set(this.endOfDayTime); + businessTime += endOfBusinessDay.diff(datetime).as(unit); + datetime = datetime.plus({ days: 1 }).set(this.startOfDayTime); + } + } + + return businessTime; + } + + /** + * Move the date in a business time (moveBehind = false) + * e.g. 06:00 => 10:00 of the current day + * e.g. 22:00 => 10:00 of the next day + * + * Move the date in a business time (moveBehind = true) + * e.g. 06:00 => 19:00 of the previous day + * e.g. 22:00 => 19:00 of the current day + * + * Warning ⚠️ _moveDateInBusinessTime doesn't retain the original timezone of the datetime in input, but it returns a datetime with the same timezone used to compute business times. + * It follows that behaviour because this method should be private and used only as helper. It is public only for testing purpose. + */ + _moveDateInBusinessTime({ + datetime, + moveBehind = false, + }: { + datetime: DateTime + moveBehind?: boolean + }) { + let date = datetime.setZone(this.businessTimezone); + const start = date.set(this.startOfDayTime); + const end = date.set(this.endOfDayTime); + + if (date < start) { + // Move datetime to the start / end of the business day + date = moveBehind + ? date.minus({ days: 1 }).set(this.endOfDayTime) + : start; + } + if (date > end) { + // Move datetime to the start of the next / previous day + date = moveBehind + ? date.set(this.endOfDayTime) + : date.plus({ days: 1 }).set(this.startOfDayTime); + } + while (this.businessDays.length && !this.isBusinessDay(date)) { + // Move datetime to the start of the next / previous business day + date = moveBehind + ? date.minus({ days: 1 }).set(this.endOfDayTime) + : date.plus({ days: 1 }).set(this.startOfDayTime); + } + return date; + } + + addBusinessHoursToDate({ + datetime, + hours, + }: { + datetime: DateTime + hours: number + }) { + return this.addBusinessSecondsToDate({ datetime, seconds: 3600 * hours }); + } + + addBusinessSecondsToDate({ + datetime, + seconds, + }: { + datetime: DateTime + seconds: number + }) { + if (seconds === 0) { + return datetime; + } + + let date = this._moveDateInBusinessTime({ datetime }); + let remainingSeconds = seconds; + while (remainingSeconds > 0) { + if (!this.isBusinessDay(date)) { + date = date.plus({ days: 1 }); + continue; + } + + const endOfBusinessDay = date.set(this.endOfDayTime); + const secondsUntilEndOfBusinessDay = endOfBusinessDay + .diff(date) + .as('seconds'); + + if (remainingSeconds <= secondsUntilEndOfBusinessDay) { + // remaining seconds are less than 1 business day + date = date.plus({ seconds: remainingSeconds }); + remainingSeconds = 0; + } + else { + // Move to the start of the next day + date = date.plus({ days: 1 }).set(this.startOfDayTime); + remainingSeconds -= secondsUntilEndOfBusinessDay; + } + } + + return date.set({ second: 0, millisecond: 0 }).setZone(datetime.zone); + } + + removeBusinessHoursFromDate({ + datetime, + hours, + }: { + datetime: DateTime + hours: number + }) { + return this.removeBusinessSecondsFromDate({ + datetime, + seconds: 3600 * hours, + }); + } + + removeBusinessSecondsFromDate({ + datetime, + seconds, + }: { + datetime: DateTime + seconds: number + }) { + if (seconds === 0) { + return datetime; + } + + let date = this._moveDateInBusinessTime({ datetime, moveBehind: true }); + let remainingSeconds = seconds; + while (remainingSeconds > 0) { + if (!this.isBusinessDay(date)) { + date = date.minus({ days: 1 }); + continue; + } + + const startOfBusinessDay + = date.hour === 0 && date.minute === 0 + ? date.minus({ days: 1 }).set(this.startOfDayTime) + : date.set(this.startOfDayTime); + const secondsFromStartOfBusinessDay = date + .diff(startOfBusinessDay) + .as('seconds'); + + if (remainingSeconds <= secondsFromStartOfBusinessDay) { + // remaining seconds are less than 1 business day + date = date.minus({ seconds: remainingSeconds }); + remainingSeconds = 0; + } + else { + // Move to the end of the previous day + date = date.minus({ days: 1 }); + + // handle special case 24h business days. If it is midnight and endOfDayTime is midnight, we must not set the date to the end of the day, otherwise we lose the effect of removing 1 day + if ( + !( + date.hour === 0 + && date.minute === 0 + && this.endOfDayTime.hour === 24 + ) + ) { + date = date.set(this.endOfDayTime); + } + remainingSeconds -= secondsFromStartOfBusinessDay; + } + } + + return date.set({ second: 0, millisecond: 0 }).setZone(datetime.zone); + } + + hoursToDays(hours: number) { + const days = hours / this.computeWorkingHours(); + return days; + } +} diff --git a/src/tools/days-calculator/days-calculator.service.test.ts b/src/tools/days-calculator/days-calculator.service.test.ts new file mode 100644 index 000000000..c2c2482e8 --- /dev/null +++ b/src/tools/days-calculator/days-calculator.service.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from 'vitest'; +import { DateTime } from 'luxon'; +import { countCertainDays, datesByDays, diffDateTimes } from './days-calculator.service'; + +describe('days-calculator', () => { + describe('diffDateTimes', () => { + it('compute right values', () => { + const daysInfos = { + saturdays: [ + '2024-08-03', + '2024-08-10', + '2024-08-17', + '2024-08-24', + '2024-08-31', + ], + tuesdays: [ + '2024-08-06', + '2024-08-13', + '2024-08-20', + '2024-08-27', + ], + sundays: [ + '2024-08-04', + '2024-08-11', + '2024-08-18', + '2024-08-25', + ], + mondays: [ + '2024-08-05', + '2024-08-12', + '2024-08-19', + '2024-08-26', + ], + fridays: [ + '2024-08-02', + '2024-08-09', + '2024-08-16', + '2024-08-23', + '2024-08-30', + ], + + wednesdays: [ + '2024-08-07', + '2024-08-14', + '2024-08-21', + '2024-08-28', + ], + thursdays: [ + '2024-08-01', + '2024-08-08', + '2024-08-15', + '2024-08-22', + '2024-08-29', + ], + weekendDays: 9, + weekends: 4, + }; + const holidays = [ + { + date: '2024-08-15 00:00:00', + end: new Date('2024-08-15T22:00:00.000Z'), + name: 'Assomption', + rule: '08-15', + start: new Date('2024-08-14T22:00:00.000Z'), + type: 'public', + }, + ]; + + const date1 = new Date('2024-08-01T07:21:46Z'); + const date2 = new Date('2024-08-31T17:21:46Z'); + + expect(diffDateTimes({ + date1, + date2, + country: 'FR', + businessTimezone: 'Europe/Paris', + includeWeekDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], + includeEndDate: true, + includeHolidays: true, + businessStartHour: 9, + businessEndHour: 18, + })).to.deep.eq({ + startDate: date1, + endDate: date2, + businessDays: 29.959691358024696, + businessHours: 269.63722222222225, + businessSeconds: 970694, + businessSecondsFormatted: '11d 5h 38m 14s', + differenceFormatted: '29d 10h', + differenceSeconds: 2541600, + totalDifferenceFormatted: '30d 10h', + totalDifferenceSeconds: 2628000, + holidays, + ...daysInfos, + }); + expect(diffDateTimes({ + date1, + date2, + country: 'FR', + businessTimezone: 'Europe/Paris', + includeEndDate: false, + includeWeekDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], + includeHolidays: true, + businessStartHour: 9, + businessEndHour: 18, + })).to.deep.eq({ + startDate: date1, + endDate: new Date('2024-08-30T23:59:59.999Z'), + businessDays: 28.959722191358026, + businessHours: 260.63749972222223, + businessSeconds: 938294.999, + businessSecondsFormatted: '10d 20h 38m 14.9s', + differenceFormatted: '28d 16h 38m 13.9s', + differenceSeconds: 2479093.999, + totalDifferenceFormatted: '29d 16h 38m 13.9s', + totalDifferenceSeconds: 2565493.999, + holidays, + ...daysInfos, + saturdays: [ + '2024-08-03', + '2024-08-10', + '2024-08-17', + '2024-08-24', + ], + }); + expect(diffDateTimes({ + date1, + date2, + country: 'FR', + businessTimezone: 'Europe/Paris', + includeEndDate: true, + includeWeekDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], + includeHolidays: false, + businessStartHour: 9, + businessEndHour: 18, + })).to.deep.eq({ + startDate: date1, + endDate: date2, + businessDays: 21.959691358024692, + businessHours: 197.63722222222222, + businessSeconds: 711494, + businessSecondsFormatted: '8d 5h 38m 14s', + differenceFormatted: '21d 14h 38m 14s', + differenceSeconds: 1867094, + totalDifferenceFormatted: '30d 10h', + totalDifferenceSeconds: 2628000, + holidays, + ...daysInfos, + }); + expect(diffDateTimes({ + date1, + date2, + country: 'FR', + businessTimezone: 'Europe/Paris', + includeEndDate: true, + includeWeekDays: ['monday'], + includeHolidays: false, + businessStartHour: 9, + businessEndHour: 18, + })).to.deep.eq({ + startDate: date1, + endDate: date2, + businessDays: 4, + businessHours: 36, + businessSeconds: 129600, + businessSecondsFormatted: '1d 12h', + differenceFormatted: '4d', + differenceSeconds: 345600, + totalDifferenceFormatted: '30d 10h', + totalDifferenceSeconds: 2628000, + holidays, + ...daysInfos, + }); + }); + }); + describe('countCertainDays', () => { + it('compute right number of days', () => { + expect(countCertainDays([1, 3, 5], new Date(2014, 8, 1), new Date(2014, 8, 1))).toBe(1); + expect(countCertainDays([1, 3, 5], new Date(2014, 8, 1), new Date(2014, 8, 2))).toBe(1); + expect(countCertainDays([1, 3, 5], new Date(2014, 8, 1), new Date(2014, 8, 3))).toBe(2); + expect(countCertainDays([1, 3, 5], new Date(2014, 8, 1), new Date(2014, 8, 4))).toBe(2); + expect(countCertainDays([1, 3, 5], new Date(2014, 8, 1), new Date(2014, 8, 5))).toBe(3); + expect(countCertainDays([1, 3, 5], new Date(2014, 8, 1), new Date(2014, 8, 6))).toBe(3); + expect(countCertainDays([1, 3, 5], new Date(2014, 8, 1), new Date(2014, 8, 7))).toBe(3); + }); + }); + describe('datesByDays', () => { + it('compute week days dates', () => { + expect(datesByDays(DateTime.utc(2014, 8, 1), DateTime.utc(2014, 8, 31))).to.deep.eq({ + 1: [ + '2014-08-04', + '2014-08-11', + '2014-08-18', + '2014-08-25', + ], + 2: [ + '2014-08-05', + '2014-08-12', + '2014-08-19', + '2014-08-26', + ], + 3: [ + '2014-08-06', + '2014-08-13', + '2014-08-20', + '2014-08-27', + ], + 4: [ + '2014-08-07', + '2014-08-14', + '2014-08-21', + '2014-08-28', + ], + 5: [ + '2014-08-01', + '2014-08-08', + '2014-08-15', + '2014-08-22', + '2014-08-29', + ], + 6: [ + '2014-08-02', + '2014-08-09', + '2014-08-16', + '2014-08-23', + '2014-08-30', + ], + 7: [ + '2014-08-03', + '2014-08-10', + '2014-08-17', + '2014-08-24', + '2014-08-31', + ], + }); + }); + }); +}); diff --git a/src/tools/days-calculator/days-calculator.service.ts b/src/tools/days-calculator/days-calculator.service.ts new file mode 100644 index 000000000..afdbf54a6 --- /dev/null +++ b/src/tools/days-calculator/days-calculator.service.ts @@ -0,0 +1,161 @@ +import { DateTime, Interval } from 'luxon'; +import prettyMilliseconds from 'pretty-ms'; +import Holidays, { type HolidaysTypes } from 'date-holidays'; +import _ from 'lodash'; +import { BusinessTime, type Holiday } from './business-time-calculator'; + +interface DateTimeRange { + startDate: Date + endDate: Date + totalDifferenceSeconds: number + totalDifferenceFormatted: string + differenceSeconds: number + differenceFormatted: string + businessSeconds: number + businessSecondsFormatted: string + businessHours: number + businessDays: number + mondays: string[] + tuesdays: string[] + wednesdays: string[] + thursdays: string[] + fridays: string[] + saturdays: string[] + sundays: string[] + weekendDays: number + weekends: number + holidays: HolidaysTypes.Holiday[] +} + +export type Weekdays = 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday'; +export const allDays: Weekdays[] = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; +export const allWeekDays: Weekdays[] = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday']; + +export function diffDateTimes({ + date1, + date2, + country, state = undefined, region = undefined, + businessTimezone, + includeEndDate = true, + includeWeekDays = allWeekDays, + includeHolidays = true, + businessStartHour = 9, + businessEndHour = 18, + +}: { + date1: Date + date2: Date + country: string + state?: string + region?: string + includeEndDate?: boolean + includeWeekDays?: Array + includeHolidays?: boolean + businessStartHour: number + businessEndHour: number + businessTimezone: string +}): DateTimeRange { + function getHolidaysBetween(date1: DateTime, date2: DateTime) { + const startDateTime = date1.startOf('day'); + const endDateTime = date2.endOf('day'); + const hd = new Holidays(country, state || '', region || ''); + let holidays: Array = []; + for (let year = startDateTime.year; year <= endDateTime.year; year += 1) { + holidays = [...holidays, ...hd.getHolidays(year)]; + } + + const range = Interval.fromDateTimes(startDateTime, endDateTime); + return holidays.filter(h => range.contains(DateTime.fromJSDate(h.start))); + } + + const startDateTime = DateTime.fromJSDate(date1); + let endDateTime = DateTime.fromJSDate(date2); + if (!includeEndDate) { + endDateTime = endDateTime.minus({ days: 1 }).endOf('day'); + } + if (endDateTime < startDateTime) { + endDateTime = startDateTime; + } + + const holidays = getHolidaysBetween(startDateTime, endDateTime); + const holidaysDates = holidays.map(h => DateTime.fromJSDate(h.start).toFormat('dd/MM/yyyy') as Holiday); + + const differenceTimeComputer = new BusinessTime({ + businessDays: includeWeekDays, + businessTimezone, + holidays: includeHolidays ? holidaysDates : [], + businessHours: [0, 24], + }); + const businessTimeComputer = new BusinessTime({ + businessDays: includeWeekDays, + businessTimezone, + holidays: includeHolidays ? holidaysDates : [], + businessHours: [businessStartHour, businessEndHour], + }); + + const startEnd = { start: startDateTime, end: endDateTime }; + + const totalDifferenceSeconds = endDateTime.diff(startDateTime, 'seconds').toObject().seconds || 0; + const differenceSeconds = differenceTimeComputer.computeBusinessSecondsInInterval(startEnd); + const businessSeconds = businessTimeComputer.computeBusinessSecondsInInterval(startEnd); + const weekDaysDates = datesByDays(startDateTime, endDateTime); + const weekendDays = countCertainDays([6, 0], date1, date2); + return { + startDate: startDateTime.toJSDate(), + endDate: endDateTime.toJSDate(), + totalDifferenceSeconds, + totalDifferenceFormatted: prettyMilliseconds(totalDifferenceSeconds * 1000), + differenceSeconds, + differenceFormatted: prettyMilliseconds(differenceSeconds * 1000), + businessSeconds, + businessSecondsFormatted: prettyMilliseconds(businessSeconds * 1000), + businessHours: businessTimeComputer.computeBusinessHoursInInterval(startEnd), + businessDays: businessTimeComputer.computeBusinessDaysInInterval(startEnd), + mondays: weekDaysDates['1'] || [], + tuesdays: weekDaysDates['2'] || [], + wednesdays: weekDaysDates['3'] || [], + thursdays: weekDaysDates['4'] || [], + fridays: weekDaysDates['5'] || [], + saturdays: weekDaysDates['6'] || [], + sundays: weekDaysDates['7'] || [], + weekendDays, + weekends: Math.floor(weekendDays / 2), + holidays, + }; +} + +// days is an array of weekdays: 0 is Sunday, ..., 6 is Saturday +export function countCertainDays(days: Array<0 | 1 | 2 | 3 | 4 | 5 | 6>, d0: Date, d1: Date) { + const ndays = 1 + Math.round((d1.getTime() - d0.getTime()) / (24 * 3600 * 1000)); + const sum = function (a: number, b: number) { + return a + Math.floor((ndays + (d0.getDay() + 6 - b) % 7) / 7); + }; + return days.reduce(sum, 0); +} + +export function datesByDays(startDateTime: DateTime, endDateTime: DateTime) { + const dates = Interval.fromDateTimes(startDateTime.startOf('day'), endDateTime.endOf('day')).splitBy({ day: 1 }).map(d => d.start); + return _.chain(dates) + .groupBy(d => d?.weekday) + .map((dates, weekday) => ({ weekday, dates })) + .reduce((prev, curr) => ({ ...prev, [curr.weekday]: mapToJSDate(curr.dates) }), {} as { [weekday: string]: string[] }) + .value(); +} +function mapToJSDate(dates: (DateTime | null)[]): string[] { + return dates.map(d => d?.toISODate() || '').filter(d => d); +} + +export function getSupportedCountries() { + const hd = new Holidays(); + return Object.entries(hd.getCountries()).map(([code, name]) => ({ value: code, label: name })); +} + +export function getSupportedStates(country: string) { + const hd = new Holidays(); + return Object.entries(hd.getStates(country) || []).map(([code, name]) => ({ value: code, label: name })); +} + +export function getSupportedRegions(country: string, state: string) { + const hd = new Holidays(); + return Object.entries(hd.getRegions(country, state) || []).map(([code, name]) => ({ value: code, label: name })); +} diff --git a/src/tools/days-calculator/days-calculator.vue b/src/tools/days-calculator/days-calculator.vue new file mode 100644 index 000000000..73ef88880 --- /dev/null +++ b/src/tools/days-calculator/days-calculator.vue @@ -0,0 +1,176 @@ + + + diff --git a/src/tools/days-calculator/index.ts b/src/tools/days-calculator/index.ts new file mode 100644 index 000000000..b4c6217e0 --- /dev/null +++ b/src/tools/days-calculator/index.ts @@ -0,0 +1,12 @@ +import { Calendar } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'Days Calculator', + path: '/days-calculator', + description: 'Calculate days interval, holidays, difference, business times', + keywords: ['days', 'interval', 'month', 'year', 'difference', 'holidays', 'calculator'], + component: () => import('./days-calculator.vue'), + icon: Calendar, + createdAt: new Date('2024-08-15'), +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index 200b947fc..670938815 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -2,9 +2,9 @@ import { tool as base64FileConverter } from './base64-file-converter'; import { tool as base64StringConverter } from './base64-string-converter'; import { tool as basicAuthGenerator } from './basic-auth-generator'; import { tool as emailNormalizer } from './email-normalizer'; - import { tool as asciiTextDrawer } from './ascii-text-drawer'; - +import { tool as daysCalculator } from './days-calculator'; +import { tool as dateDurationCalculator } from './date-duration-calculator'; import { tool as textToUnicode } from './text-to-unicode'; import { tool as safelinkDecoder } from './safelink-decoder'; import { tool as xmlToJson } from './xml-to-json'; @@ -176,7 +176,9 @@ export const toolsByCategory: ToolCategory[] = [ components: [ chronometer, temperatureConverter, + daysCalculator, durationCalculator, + dateDurationCalculator, benchmarkBuilder, ], },