feat(new tools): Date+Duration and Days Calculator
Allows computing specific date + some durations Allows computing Date interval with many options (include end date, select week days, holidays, business time) and output many statistics Fix #778, #584, #971
This commit is contained in:
		
							parent
							
								
									4c381f3b6d
								
							
						
					
					
						commit
						dec2e31654
					
				
							
								
								
									
										11
									
								
								components.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										11
									
								
								components.d.ts
									
									
									
									
										vendored
									
									
								
							| @ -64,7 +64,9 @@ declare module '@vue/runtime-core' { | |||||||
|     'CTextCopyable.demo': typeof import('./src/ui/c-text-copyable/c-text-copyable.demo.vue')['default'] |     '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: typeof import('./src/ui/c-tooltip/c-tooltip.vue')['default'] | ||||||
|     'CTooltip.demo': typeof import('./src/ui/c-tooltip/c-tooltip.demo.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'] |     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'] |     'DemoHome.page': typeof import('./src/ui/demo/demo-home.page.vue')['default'] | ||||||
|     DemoWrapper: typeof import('./src/ui/demo/demo-wrapper.vue')['default'] |     DemoWrapper: typeof import('./src/ui/demo/demo-wrapper.vue')['default'] | ||||||
|     DeviceInformation: typeof import('./src/tools/device-information/device-information.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'] |     MetaTagGenerator: typeof import('./src/tools/meta-tag-generator/meta-tag-generator.vue')['default'] | ||||||
|     MimeTypes: typeof import('./src/tools/mime-types/mime-types.vue')['default'] |     MimeTypes: typeof import('./src/tools/mime-types/mime-types.vue')['default'] | ||||||
|     NavbarButtons: typeof import('./src/components/NavbarButtons.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'] |     NCode: typeof import('naive-ui')['NCode'] | ||||||
|     NCollapseTransition: typeof import('naive-ui')['NCollapseTransition'] |     NCollapseTransition: typeof import('naive-ui')['NCollapseTransition'] | ||||||
|     NConfigProvider: typeof import('naive-ui')['NConfigProvider'] |     NConfigProvider: typeof import('naive-ui')['NConfigProvider'] | ||||||
|  |     NDatePicker: typeof import('naive-ui')['NDatePicker'] | ||||||
|     NDivider: typeof import('naive-ui')['NDivider'] |     NDivider: typeof import('naive-ui')['NDivider'] | ||||||
|     NEllipsis: typeof import('naive-ui')['NEllipsis'] |     NEllipsis: typeof import('naive-ui')['NEllipsis'] | ||||||
|     NGi: typeof import('naive-ui')['NGi'] |     NFormItem: typeof import('naive-ui')['NFormItem'] | ||||||
|     NGrid: typeof import('naive-ui')['NGrid'] |  | ||||||
|     NH1: typeof import('naive-ui')['NH1'] |     NH1: typeof import('naive-ui')['NH1'] | ||||||
|     NH3: typeof import('naive-ui')['NH3'] |     NH3: typeof import('naive-ui')['NH3'] | ||||||
|     NIcon: typeof import('naive-ui')['NIcon'] |     NIcon: typeof import('naive-ui')['NIcon'] | ||||||
|  |     NInputNumber: typeof import('naive-ui')['NInputNumber'] | ||||||
|     NLayout: typeof import('naive-ui')['NLayout'] |     NLayout: typeof import('naive-ui')['NLayout'] | ||||||
|     NLayoutSider: typeof import('naive-ui')['NLayoutSider'] |     NLayoutSider: typeof import('naive-ui')['NLayoutSider'] | ||||||
|     NMenu: typeof import('naive-ui')['NMenu'] |     NMenu: typeof import('naive-ui')['NMenu'] | ||||||
|     NP: typeof import('naive-ui')['NP'] |     NP: typeof import('naive-ui')['NP'] | ||||||
|     NScrollbar: typeof import('naive-ui')['NScrollbar'] |     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'] |     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'] |     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'] |     PasswordStrengthAnalyser: typeof import('./src/tools/password-strength-analyser/password-strength-analyser.vue')['default'] | ||||||
|  | |||||||
| @ -44,6 +44,7 @@ | |||||||
|     "@tiptap/starter-kit": "2.1.6", |     "@tiptap/starter-kit": "2.1.6", | ||||||
|     "@tiptap/vue-3": "2.0.3", |     "@tiptap/vue-3": "2.0.3", | ||||||
|     "@types/figlet": "^1.5.8", |     "@types/figlet": "^1.5.8", | ||||||
|  |     "@types/luxon": "^3.4.2", | ||||||
|     "@types/markdown-it": "^13.0.7", |     "@types/markdown-it": "^13.0.7", | ||||||
|     "@vicons/material": "^0.12.0", |     "@vicons/material": "^0.12.0", | ||||||
|     "@vicons/tabler": "^0.12.0", |     "@vicons/tabler": "^0.12.0", | ||||||
| @ -54,11 +55,13 @@ | |||||||
|     "change-case": "^4.1.2", |     "change-case": "^4.1.2", | ||||||
|     "colord": "^2.9.3", |     "colord": "^2.9.3", | ||||||
|     "composerize-ts": "^0.6.2", |     "composerize-ts": "^0.6.2", | ||||||
|  |     "countries-and-timezones": "^3.7.2", | ||||||
|     "country-code-lookup": "^0.1.0", |     "country-code-lookup": "^0.1.0", | ||||||
|     "cron-validator": "^1.3.1", |     "cron-validator": "^1.3.1", | ||||||
|     "cronstrue": "^2.26.0", |     "cronstrue": "^2.26.0", | ||||||
|     "crypto-js": "^4.1.1", |     "crypto-js": "^4.1.1", | ||||||
|     "date-fns": "^2.29.3", |     "date-fns": "^2.29.3", | ||||||
|  |     "date-holidays": "^3.23.12", | ||||||
|     "dompurify": "^3.0.6", |     "dompurify": "^3.0.6", | ||||||
|     "duration-fns": "^3.0.2", |     "duration-fns": "^3.0.2", | ||||||
|     "email-normalizer": "^1.0.0", |     "email-normalizer": "^1.0.0", | ||||||
| @ -74,6 +77,7 @@ | |||||||
|     "jwt-decode": "^3.1.2", |     "jwt-decode": "^3.1.2", | ||||||
|     "libphonenumber-js": "^1.10.28", |     "libphonenumber-js": "^1.10.28", | ||||||
|     "lodash": "^4.17.21", |     "lodash": "^4.17.21", | ||||||
|  |     "luxon": "^3.5.0", | ||||||
|     "markdown-it": "^14.0.0", |     "markdown-it": "^14.0.0", | ||||||
|     "marked": "^10.0.0", |     "marked": "^10.0.0", | ||||||
|     "mathjs": "^11.9.1", |     "mathjs": "^11.9.1", | ||||||
|  | |||||||
							
								
								
									
										100
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										100
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							| @ -29,6 +29,9 @@ dependencies: | |||||||
|   '@types/figlet': |   '@types/figlet': | ||||||
|     specifier: ^1.5.8 |     specifier: ^1.5.8 | ||||||
|     version: 1.5.8 |     version: 1.5.8 | ||||||
|  |   '@types/luxon': | ||||||
|  |     specifier: ^3.4.2 | ||||||
|  |     version: 3.4.2 | ||||||
|   '@types/markdown-it': |   '@types/markdown-it': | ||||||
|     specifier: ^13.0.7 |     specifier: ^13.0.7 | ||||||
|     version: 13.0.9 |     version: 13.0.9 | ||||||
| @ -59,6 +62,9 @@ dependencies: | |||||||
|   composerize-ts: |   composerize-ts: | ||||||
|     specifier: ^0.6.2 |     specifier: ^0.6.2 | ||||||
|     version: 0.6.2 |     version: 0.6.2 | ||||||
|  |   countries-and-timezones: | ||||||
|  |     specifier: ^3.7.2 | ||||||
|  |     version: 3.7.2 | ||||||
|   country-code-lookup: |   country-code-lookup: | ||||||
|     specifier: ^0.1.0 |     specifier: ^0.1.0 | ||||||
|     version: 0.1.0 |     version: 0.1.0 | ||||||
| @ -74,6 +80,9 @@ dependencies: | |||||||
|   date-fns: |   date-fns: | ||||||
|     specifier: ^2.29.3 |     specifier: ^2.29.3 | ||||||
|     version: 2.29.3 |     version: 2.29.3 | ||||||
|  |   date-holidays: | ||||||
|  |     specifier: ^3.23.12 | ||||||
|  |     version: 3.23.12 | ||||||
|   dompurify: |   dompurify: | ||||||
|     specifier: ^3.0.6 |     specifier: ^3.0.6 | ||||||
|     version: 3.0.6 |     version: 3.0.6 | ||||||
| @ -119,6 +128,9 @@ dependencies: | |||||||
|   lodash: |   lodash: | ||||||
|     specifier: ^4.17.21 |     specifier: ^4.17.21 | ||||||
|     version: 4.17.21 |     version: 4.17.21 | ||||||
|  |   luxon: | ||||||
|  |     specifier: ^3.5.0 | ||||||
|  |     version: 3.5.0 | ||||||
|   markdown-it: |   markdown-it: | ||||||
|     specifier: ^14.0.0 |     specifier: ^14.0.0 | ||||||
|     version: 14.1.0 |     version: 14.1.0 | ||||||
| @ -3005,6 +3017,10 @@ packages: | |||||||
|     resolution: {integrity: sha512-YI/M/4HRImtNf3pJgbF+W6FrXovqj+T+/HpENLTooK9PnkacBsDpeP3IpHab40CClUfhNmdM2WTNP2sa2dni5Q==} |     resolution: {integrity: sha512-YI/M/4HRImtNf3pJgbF+W6FrXovqj+T+/HpENLTooK9PnkacBsDpeP3IpHab40CClUfhNmdM2WTNP2sa2dni5Q==} | ||||||
|     dev: false |     dev: false | ||||||
| 
 | 
 | ||||||
|  |   /@types/luxon@3.4.2: | ||||||
|  |     resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} | ||||||
|  |     dev: false | ||||||
|  | 
 | ||||||
|   /@types/markdown-it@12.2.3: |   /@types/markdown-it@12.2.3: | ||||||
|     resolution: {integrity: sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==} |     resolution: {integrity: sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==} | ||||||
|     dependencies: |     dependencies: | ||||||
| @ -4192,6 +4208,11 @@ packages: | |||||||
|     resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} |     resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} | ||||||
|     dev: true |     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: |   /async-validator@4.2.5: | ||||||
|     resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} |     resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} | ||||||
|     dev: false |     dev: false | ||||||
| @ -4350,6 +4371,13 @@ packages: | |||||||
|     engines: {node: '>=8'} |     engines: {node: '>=8'} | ||||||
|     dev: true |     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: |   /call-bind@1.0.5: | ||||||
|     resolution: {integrity: sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==} |     resolution: {integrity: sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==} | ||||||
|     dependencies: |     dependencies: | ||||||
| @ -4666,6 +4694,11 @@ packages: | |||||||
|       browserslist: 4.22.1 |       browserslist: 4.22.1 | ||||||
|     dev: true |     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: |   /country-code-lookup@0.1.0: | ||||||
|     resolution: {integrity: sha512-IOI66HEG+8bXfWPy+sTzuN7161vmDZOHg1wgIPFf3WfD73FeLajnn6C+fnxOIa9RL1WRBDMXQQWW/FOaOYaQ3w==} |     resolution: {integrity: sha512-IOI66HEG+8bXfWPy+sTzuN7161vmDZOHg1wgIPFf3WfD73FeLajnn6C+fnxOIa9RL1WRBDMXQQWW/FOaOYaQ3w==} | ||||||
|     dev: false |     dev: false | ||||||
| @ -4783,6 +4816,23 @@ packages: | |||||||
|       whatwg-url: 12.0.1 |       whatwg-url: 12.0.1 | ||||||
|     dev: true |     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): |   /date-fns-tz@2.0.0(date-fns@2.30.0): | ||||||
|     resolution: {integrity: sha512-OAtcLdB9vxSXTWHdT8b398ARImVwQMyjfYGkKD2zaGpHseG2UPHbHjXELReErZFxWdSLph3c2zOaaTyHfOhERQ==} |     resolution: {integrity: sha512-OAtcLdB9vxSXTWHdT8b398ARImVwQMyjfYGkKD2zaGpHseG2UPHbHjXELReErZFxWdSLph3c2zOaaTyHfOhERQ==} | ||||||
|     peerDependencies: |     peerDependencies: | ||||||
| @ -4803,6 +4853,31 @@ packages: | |||||||
|       '@babel/runtime': 7.23.2 |       '@babel/runtime': 7.23.2 | ||||||
|     dev: false |     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: |   /de-indent@1.0.2: | ||||||
|     resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} |     resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} | ||||||
|     dev: false |     dev: false | ||||||
| @ -6521,6 +6596,10 @@ packages: | |||||||
|       minimatch: 3.1.2 |       minimatch: 3.1.2 | ||||||
|     dev: true |     dev: true | ||||||
| 
 | 
 | ||||||
|  |   /jalaali-js@1.2.7: | ||||||
|  |     resolution: {integrity: sha512-gE+YHWSbygYAoJa+Xg8LWxGILqFOxZSBQQw39ghel01fVFUxV7bjL0x1JFsHcLQ3uPjvn81HQMa+kxwyPWnxGQ==} | ||||||
|  |     dev: false | ||||||
|  | 
 | ||||||
|   /javascript-natural-sort@0.7.1: |   /javascript-natural-sort@0.7.1: | ||||||
|     resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} |     resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} | ||||||
|     dev: false |     dev: false | ||||||
| @ -6573,7 +6652,6 @@ packages: | |||||||
|     hasBin: true |     hasBin: true | ||||||
|     dependencies: |     dependencies: | ||||||
|       argparse: 2.0.1 |       argparse: 2.0.1 | ||||||
|     dev: true |  | ||||||
| 
 | 
 | ||||||
|   /jsbn@1.1.0: |   /jsbn@1.1.0: | ||||||
|     resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} |     resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} | ||||||
| @ -6830,6 +6908,11 @@ packages: | |||||||
|     dependencies: |     dependencies: | ||||||
|       yallist: 4.0.0 |       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: |   /magic-string@0.25.9: | ||||||
|     resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} |     resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} | ||||||
|     dependencies: |     dependencies: | ||||||
| @ -7036,6 +7119,16 @@ packages: | |||||||
|       pkg-types: 1.0.3 |       pkg-types: 1.0.3 | ||||||
|       ufo: 1.3.1 |       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: |   /monaco-editor@0.43.0: | ||||||
|     resolution: {integrity: sha512-cnoqwQi/9fml2Szamv1XbSJieGJ1Dc8tENVMD26Kcfl7xGQWp7OBKMjlwKVGYFJ3/AXJjSOGvcqK7Ry/j9BM1Q==} |     resolution: {integrity: sha512-cnoqwQi/9fml2Szamv1XbSJieGJ1Dc8tENVMD26Kcfl7xGQWp7OBKMjlwKVGYFJ3/AXJjSOGvcqK7Ry/j9BM1Q==} | ||||||
|     dev: false |     dev: false | ||||||
| @ -7570,6 +7663,11 @@ packages: | |||||||
|     engines: {node: '>= 0.8.0'} |     engines: {node: '>= 0.8.0'} | ||||||
|     dev: true |     dev: true | ||||||
| 
 | 
 | ||||||
|  |   /prepin@1.0.3: | ||||||
|  |     resolution: {integrity: sha512-0XL2hreherEEvUy0fiaGEfN/ioXFV+JpImqIzQjxk6iBg4jQ2ARKqvC4+BmRD8w/pnpD+lbxvh0Ub+z7yBEjvA==} | ||||||
|  |     hasBin: true | ||||||
|  |     dev: false | ||||||
|  | 
 | ||||||
|   /prettier@3.0.0: |   /prettier@3.0.0: | ||||||
|     resolution: {integrity: sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g==} |     resolution: {integrity: sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g==} | ||||||
|     engines: {node: '>=14'} |     engines: {node: '>=14'} | ||||||
|  | |||||||
| @ -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: [], | ||||||
|  |         }, | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
| @ -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, | ||||||
|  |   }; | ||||||
|  | } | ||||||
| @ -0,0 +1,41 @@ | |||||||
|  | <script setup lang="ts"> | ||||||
|  | import { addToDate } from './date-duration-calculator.service'; | ||||||
|  | 
 | ||||||
|  | const now = Date.now(); | ||||||
|  | 
 | ||||||
|  | const inputReferenceDate = ref(now); | ||||||
|  | const inputDurations = ref(''); | ||||||
|  | const resultDateAdder = computed(() => addToDate(new Date(inputReferenceDate.value), inputDurations.value)); | ||||||
|  | const errorsDateAdder = computed(() => resultDateAdder.value.errors.join('\n')); | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |   <div> | ||||||
|  |     <c-card title="Date + Duration Calculator" mb-2> | ||||||
|  |       <n-form-item label="Reference Date:" label-placement="left" mb-1> | ||||||
|  |         <n-date-picker v-model:value="inputReferenceDate" type="datetime" /> | ||||||
|  |       </n-form-item> | ||||||
|  | 
 | ||||||
|  |       <c-input-text | ||||||
|  |         v-model:value="inputDurations" | ||||||
|  |         multiline | ||||||
|  |         rows="5" | ||||||
|  |         label="Duration(s)" | ||||||
|  |         placeholder="Please enter duration, one per line with optional sign" | ||||||
|  |         mb-2 | ||||||
|  |       /> | ||||||
|  |       <n-p>Supports: comment (# line), HH:MM:SS.FFF, 3d 1h 3s..., P4DT12H20M20.3S..</n-p> | ||||||
|  | 
 | ||||||
|  |       <c-card v-if="errorsDateAdder" title="Lines errors"> | ||||||
|  |         <textarea-copyable :value="errorsDateAdder" /> | ||||||
|  |       </c-card> | ||||||
|  | 
 | ||||||
|  |       <n-divider /> | ||||||
|  | 
 | ||||||
|  |       <input-copyable v-if="resultDateAdder" label="Result Date:" label-position="left" label-width="150px" :value="resultDateAdder.date.toString()" mb-1 /> | ||||||
|  |       <input-copyable v-if="resultDateAdder" label="Result ISO Date:" label-position="left" label-width="150px" :value="resultDateAdder.date.toISOString()" mb-1 /> | ||||||
|  |       <input-copyable v-if="resultDateAdder" label="Duration (seconds):" label-position="left" label-width="150px" :value="resultDateAdder.durationSeconds" mb-1 /> | ||||||
|  |       <input-copyable v-if="resultDateAdder" label="Duration:" label-position="left" label-width="150px" :value="resultDateAdder.durationPretty" mb-1 /> | ||||||
|  |     </c-card> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
							
								
								
									
										12
									
								
								src/tools/date-duration-calculator/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/tools/date-duration-calculator/index.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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'), | ||||||
|  | }); | ||||||
							
								
								
									
										611
									
								
								src/tools/days-calculator/business-time-calculator.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										611
									
								
								src/tools/days-calculator/business-time-calculator.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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<typeof BusinessTime>; | ||||||
|  | 
 | ||||||
|  | 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); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										341
									
								
								src/tools/days-calculator/business-time-calculator.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										341
									
								
								src/tools/days-calculator/business-time-calculator.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										238
									
								
								src/tools/days-calculator/days-calculator.service.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										238
									
								
								src/tools/days-calculator/days-calculator.service.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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', | ||||||
|  |         ], | ||||||
|  |       }); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										161
									
								
								src/tools/days-calculator/days-calculator.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										161
									
								
								src/tools/days-calculator/days-calculator.service.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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<Weekdays> | ||||||
|  |   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<HolidaysTypes.Holiday> = []; | ||||||
|  |     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 })); | ||||||
|  | } | ||||||
							
								
								
									
										176
									
								
								src/tools/days-calculator/days-calculator.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								src/tools/days-calculator/days-calculator.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,176 @@ | |||||||
|  | <script setup lang="ts"> | ||||||
|  | import ctz from 'countries-and-timezones'; | ||||||
|  | import { type Weekdays, allWeekDays, diffDateTimes, getSupportedCountries, getSupportedRegions, getSupportedStates } from './days-calculator.service'; | ||||||
|  | import { useQueryParamOrStorage } from '@/composable/queryParams'; | ||||||
|  | 
 | ||||||
|  | const now = Date.now(); | ||||||
|  | 
 | ||||||
|  | const inputDateRange = ref<[number, number]>([now, now + 86400]); | ||||||
|  | 
 | ||||||
|  | const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; | ||||||
|  | const allTimezones = Object.values(ctz.getAllTimezones()).map(tz => ({ | ||||||
|  |   value: tz.name, | ||||||
|  |   label: `${tz.name === browserTimezone ? 'Browser TZ - ' : ''}${tz.name} (${tz.utcOffset === tz.dstOffset ? tz.utcOffsetStr : `${tz.utcOffsetStr}/${tz.dstOffsetStr}`})`, | ||||||
|  | })); | ||||||
|  | const allCountries = ref(getSupportedCountries()); | ||||||
|  | const country = useQueryParamOrStorage({ name: 'country', storageName: 'days-calc:ctr', defaultValue: 'FR' }); | ||||||
|  | const possibleStates = computed(() => getSupportedStates(country.value)); | ||||||
|  | const state = useQueryParamOrStorage({ name: 'state', storageName: 'days-calc:st', defaultValue: '' }); | ||||||
|  | const possibleRegions = computed(() => getSupportedRegions(country.value, state.value)); | ||||||
|  | const region = useQueryParamOrStorage({ name: 'region', storageName: 'days-calc:reg', defaultValue: '' }); | ||||||
|  | const includeEndDate = useQueryParamOrStorage({ name: 'includeend', storageName: 'days-calc:end', defaultValue: false }); | ||||||
|  | const includeWeekDays = useQueryParamOrStorage<Weekdays[]>({ name: 'days', storageName: 'days-calc:days', defaultValue: allWeekDays }); | ||||||
|  | const includeHolidays = useQueryParamOrStorage({ name: 'includehol', storageName: 'days-calc:hol', defaultValue: true }); | ||||||
|  | const businessStartHour = useQueryParamOrStorage({ name: 'businessstart', storageName: 'days-calc:bss', defaultValue: 9 }); | ||||||
|  | const businessEndHour = useQueryParamOrStorage({ name: 'businessstend', storageName: 'days-calc:bse', defaultValue: 18 }); | ||||||
|  | const businessTimezone = useQueryParamOrStorage({ name: 'tz', storageName: 'days-calc:tz', defaultValue: browserTimezone }); | ||||||
|  | const error = ref(''); | ||||||
|  | const resultDaysDiff = computed(() => { | ||||||
|  |   try { | ||||||
|  |     return diffDateTimes({ | ||||||
|  |       date1: new Date(inputDateRange.value[0] / 1000 * 1000), | ||||||
|  |       date2: new Date(inputDateRange.value[1] / 1000 * 1000), | ||||||
|  |       country: country.value, | ||||||
|  |       state: state.value, | ||||||
|  |       region: region.value, | ||||||
|  |       businessTimezone: businessTimezone.value, | ||||||
|  |       includeEndDate: includeEndDate.value, | ||||||
|  |       includeWeekDays: includeWeekDays.value, | ||||||
|  |       includeHolidays: includeHolidays.value, | ||||||
|  |       businessStartHour: businessStartHour.value, | ||||||
|  |       businessEndHour: businessEndHour.value, | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |   catch (e: any) { | ||||||
|  |     error.value = e.toString(); | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | const inputProps = { | ||||||
|  |   'labelPosition': 'left', | ||||||
|  |   'labelWidth': '170px', | ||||||
|  |   'readonly': true, | ||||||
|  |   'mb-2': '', | ||||||
|  | } as const; | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <template> | ||||||
|  |   <div> | ||||||
|  |     <c-card title="Dates Interval" mb-2> | ||||||
|  |       <n-form-item label="Date Range:" label-placement="left" label-width="100px" label-align="left" mb-1> | ||||||
|  |         <n-date-picker v-model:value="inputDateRange" type="datetimerange" /> | ||||||
|  |       </n-form-item> | ||||||
|  | 
 | ||||||
|  |       <c-select | ||||||
|  |         v-model:value="country" | ||||||
|  |         label-position="left" | ||||||
|  |         label-width="100px" | ||||||
|  |         searchable | ||||||
|  |         label="Country:" | ||||||
|  |         :options="allCountries" | ||||||
|  |         mb-1 | ||||||
|  |       /> | ||||||
|  |       <c-select | ||||||
|  |         v-if="possibleStates?.length > 0" | ||||||
|  |         v-model:value="state" | ||||||
|  |         label-position="left" | ||||||
|  |         label-width="100px" | ||||||
|  |         searchable | ||||||
|  |         label="State:" | ||||||
|  |         :options="possibleStates" | ||||||
|  |         placeholder="Select a specific state or let empty for general info" | ||||||
|  |         mb-1 | ||||||
|  |       /> | ||||||
|  |       <c-select | ||||||
|  |         v-if="possibleRegions?.length > 0" | ||||||
|  |         v-model:value="region" | ||||||
|  |         label-position="left" | ||||||
|  |         label-width="100px" | ||||||
|  |         searchable | ||||||
|  |         label="Region:" | ||||||
|  |         :options="possibleRegions" | ||||||
|  |         mb-1 | ||||||
|  |       /> | ||||||
|  |       <c-select | ||||||
|  |         v-model:value="businessTimezone" | ||||||
|  |         label-position="left" | ||||||
|  |         label-width="100px" | ||||||
|  |         searchable | ||||||
|  |         label="Timezone:" | ||||||
|  |         :options="allTimezones" | ||||||
|  |         mb-2 | ||||||
|  |       /> | ||||||
|  | 
 | ||||||
|  |       <div mb-2 flex items-baseline gap-2> | ||||||
|  |         <n-form-item label="Business Start Hour:" label-placement="left" flex-1> | ||||||
|  |           <n-input-number v-model:value="businessStartHour" :min="0" :max="24" /> | ||||||
|  |         </n-form-item> | ||||||
|  |         <n-form-item label="Business End Hour:" label-placement="left" flex-1> | ||||||
|  |           <n-input-number v-model:value="businessEndHour" :min="0" :max="24" /> | ||||||
|  |         </n-form-item> | ||||||
|  |       </div> | ||||||
|  | 
 | ||||||
|  |       <div mb-2 flex items-baseline justify-center gap-2> | ||||||
|  |         <n-checkbox v-model:checked="includeHolidays"> | ||||||
|  |           Include Holidays | ||||||
|  |         </n-checkbox> | ||||||
|  |         <n-checkbox v-model:checked="includeEndDate"> | ||||||
|  |           Include End Date | ||||||
|  |         </n-checkbox> | ||||||
|  |       </div> | ||||||
|  | 
 | ||||||
|  |       <c-card title="Weekdays"> | ||||||
|  |         <n-checkbox-group v-model:value="includeWeekDays"> | ||||||
|  |           <n-space justify="center"> | ||||||
|  |             <n-checkbox value="monday" label="Monday" /> | ||||||
|  |             <n-checkbox value="tuesday" label="Tuesday" /> | ||||||
|  |             <n-checkbox value="wednesday" label="Wednesday" /> | ||||||
|  |             <n-checkbox value="thursday" label="Thursday" /> | ||||||
|  |             <n-checkbox value="friday" label="Friday" /> | ||||||
|  |             <n-checkbox value="saturday" label="Saturday" /> | ||||||
|  |             <n-checkbox value="sunday" label="Sunday" /> | ||||||
|  |           </n-space> | ||||||
|  |         </n-checkbox-group> | ||||||
|  |       </c-card> | ||||||
|  | 
 | ||||||
|  |       <n-divider /> | ||||||
|  | 
 | ||||||
|  |       <c-alert v-if="error"> | ||||||
|  |         {{ error }} | ||||||
|  |       </c-alert> | ||||||
|  | 
 | ||||||
|  |       <c-card v-if="resultDaysDiff" title="Result"> | ||||||
|  |         <input-copyable v-bind="inputProps" label="Start Date" :value="resultDaysDiff.startDate" /> | ||||||
|  |         <input-copyable v-bind="inputProps" label="Start Date (ISO)" :value="resultDaysDiff.startDate.toISOString()" /> | ||||||
|  |         <input-copyable v-bind="inputProps" label="End Date" :value="resultDaysDiff.endDate" /> | ||||||
|  |         <input-copyable v-bind="inputProps" label="End Date (ISO)" :value="resultDaysDiff.endDate.toISOString()" /> | ||||||
|  |         <n-divider /> | ||||||
|  |         <input-copyable v-bind="inputProps" label="Total Difference Seconds" :value="resultDaysDiff.totalDifferenceSeconds" /> | ||||||
|  |         <input-copyable v-bind="inputProps" label="Total Difference" :value="resultDaysDiff.totalDifferenceFormatted" /> | ||||||
|  |         <input-copyable v-bind="inputProps" label="Difference Seconds" :value="resultDaysDiff.differenceSeconds" /> | ||||||
|  |         <input-copyable v-bind="inputProps" label="Difference " :value="resultDaysDiff.differenceFormatted" /> | ||||||
|  |         <input-copyable v-bind="inputProps" label="Business Seconds" :value="resultDaysDiff.businessSeconds" /> | ||||||
|  |         <input-copyable v-bind="inputProps" label="Business Time" :value="resultDaysDiff.businessSecondsFormatted" /> | ||||||
|  |         <input-copyable v-bind="inputProps" label="Business Hours" :value="resultDaysDiff.businessHours" /> | ||||||
|  |         <input-copyable v-bind="inputProps" label="Business Days" :value="resultDaysDiff.businessDays" /> | ||||||
|  |         <input-copyable v-bind="inputProps" placeholder="None" label="Mondays" :value="resultDaysDiff.mondays" /> | ||||||
|  |         <input-copyable v-bind="inputProps" placeholder="None" label="Tuesdays" :value="resultDaysDiff.tuesdays" /> | ||||||
|  |         <input-copyable v-bind="inputProps" placeholder="None" label="Wednesdays" :value="resultDaysDiff.wednesdays" /> | ||||||
|  |         <input-copyable v-bind="inputProps" placeholder="None" label="Thursdays" :value="resultDaysDiff.thursdays" /> | ||||||
|  |         <input-copyable v-bind="inputProps" placeholder="None" label="Fridays" :value="resultDaysDiff.fridays" /> | ||||||
|  |         <input-copyable v-bind="inputProps" placeholder="None" label="Saturdays" :value="resultDaysDiff.saturdays" /> | ||||||
|  |         <input-copyable v-bind="inputProps" placeholder="None" label="Sundays" :value="resultDaysDiff.sundays" /> | ||||||
|  |         <input-copyable v-bind="inputProps" label="Weekend Days" :value="resultDaysDiff.weekendDays" /> | ||||||
|  |         <input-copyable v-bind="inputProps" label="Full Weekends" :value="resultDaysDiff.weekends" /> | ||||||
|  |         <c-card v-if="resultDaysDiff.holidays?.length" title="Holidays in period"> | ||||||
|  |           <ul> | ||||||
|  |             <li v-for="(holiday, index) in resultDaysDiff.holidays" :key="index"> | ||||||
|  |               {{ holiday.date }}: {{ holiday.name }} ({{ holiday.type }}) | ||||||
|  |             </li> | ||||||
|  |           </ul> | ||||||
|  |         </c-card> | ||||||
|  |       </c-card> | ||||||
|  |     </c-card> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
							
								
								
									
										12
									
								
								src/tools/days-calculator/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/tools/days-calculator/index.ts
									
									
									
									
									
										Normal file
									
								
							| @ -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'), | ||||||
|  | }); | ||||||
| @ -2,9 +2,9 @@ import { tool as base64FileConverter } from './base64-file-converter'; | |||||||
| import { tool as base64StringConverter } from './base64-string-converter'; | import { tool as base64StringConverter } from './base64-string-converter'; | ||||||
| import { tool as basicAuthGenerator } from './basic-auth-generator'; | import { tool as basicAuthGenerator } from './basic-auth-generator'; | ||||||
| import { tool as emailNormalizer } from './email-normalizer'; | import { tool as emailNormalizer } from './email-normalizer'; | ||||||
| 
 |  | ||||||
| import { tool as asciiTextDrawer } from './ascii-text-drawer'; | 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 textToUnicode } from './text-to-unicode'; | ||||||
| import { tool as safelinkDecoder } from './safelink-decoder'; | import { tool as safelinkDecoder } from './safelink-decoder'; | ||||||
| import { tool as xmlToJson } from './xml-to-json'; | import { tool as xmlToJson } from './xml-to-json'; | ||||||
| @ -176,7 +176,9 @@ export const toolsByCategory: ToolCategory[] = [ | |||||||
|     components: [ |     components: [ | ||||||
|       chronometer, |       chronometer, | ||||||
|       temperatureConverter, |       temperatureConverter, | ||||||
|  |       daysCalculator, | ||||||
|       durationCalculator, |       durationCalculator, | ||||||
|  |       dateDurationCalculator, | ||||||
|       benchmarkBuilder, |       benchmarkBuilder, | ||||||
|     ], |     ], | ||||||
|   }, |   }, | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user