mirror of
https://github.com/CorentinTh/it-tools.git
synced 2025-05-07 14:57:12 -04:00
Merge f416d76ba9
into 07eea0f484
This commit is contained in:
commit
e38f341cc6
21 changed files with 8917 additions and 8342 deletions
11
components.d.ts
vendored
11
components.d.ts
vendored
|
@ -64,12 +64,15 @@ declare module '@vue/runtime-core' {
|
|||
'CTextCopyable.demo': typeof import('./src/ui/c-text-copyable/c-text-copyable.demo.vue')['default']
|
||||
CTooltip: typeof import('./src/ui/c-tooltip/c-tooltip.vue')['default']
|
||||
'CTooltip.demo': typeof import('./src/ui/c-tooltip/c-tooltip.demo.vue')['default']
|
||||
DateDurationCalculator: typeof import('./src/tools/date-duration-calculator/date-duration-calculator.vue')['default']
|
||||
DateTimeConverter: typeof import('./src/tools/date-time-converter/date-time-converter.vue')['default']
|
||||
DaysCalculator: typeof import('./src/tools/days-calculator/days-calculator.vue')['default']
|
||||
'DemoHome.page': typeof import('./src/ui/demo/demo-home.page.vue')['default']
|
||||
DemoWrapper: typeof import('./src/ui/demo/demo-wrapper.vue')['default']
|
||||
DeviceInformation: typeof import('./src/tools/device-information/device-information.vue')['default']
|
||||
DiffViewer: typeof import('./src/tools/json-diff/diff-viewer/diff-viewer.vue')['default']
|
||||
DockerRunToDockerComposeConverter: typeof import('./src/tools/docker-run-to-docker-compose-converter/docker-run-to-docker-compose-converter.vue')['default']
|
||||
DurationCalculator: typeof import('./src/tools/duration-calculator/duration-calculator.vue')['default']
|
||||
DynamicValues: typeof import('./src/tools/benchmark-builder/dynamic-values.vue')['default']
|
||||
Editor: typeof import('./src/tools/html-wysiwyg-editor/editor/editor.vue')['default']
|
||||
EmailNormalizer: typeof import('./src/tools/email-normalizer/email-normalizer.vue')['default']
|
||||
|
@ -131,18 +134,24 @@ declare module '@vue/runtime-core' {
|
|||
MimeTypes: typeof import('./src/tools/mime-types/mime-types.vue')['default']
|
||||
NavbarButtons: typeof import('./src/components/NavbarButtons.vue')['default']
|
||||
NCheckbox: typeof import('naive-ui')['NCheckbox']
|
||||
NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup']
|
||||
NCode: typeof import('naive-ui')['NCode']
|
||||
NCollapseTransition: typeof import('naive-ui')['NCollapseTransition']
|
||||
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
|
||||
NDatePicker: typeof import('naive-ui')['NDatePicker']
|
||||
NDivider: typeof import('naive-ui')['NDivider']
|
||||
NEllipsis: typeof import('naive-ui')['NEllipsis']
|
||||
NFormItem: typeof import('naive-ui')['NFormItem']
|
||||
NH1: typeof import('naive-ui')['NH1']
|
||||
NH3: typeof import('naive-ui')['NH3']
|
||||
NIcon: typeof import('naive-ui')['NIcon']
|
||||
NInputNumber: typeof import('naive-ui')['NInputNumber']
|
||||
NLayout: typeof import('naive-ui')['NLayout']
|
||||
NLayoutSider: typeof import('naive-ui')['NLayoutSider']
|
||||
NMenu: typeof import('naive-ui')['NMenu']
|
||||
NP: typeof import('naive-ui')['NP']
|
||||
NScrollbar: typeof import('naive-ui')['NScrollbar']
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
NTable: typeof import('naive-ui')['NTable']
|
||||
NumeronymGenerator: typeof import('./src/tools/numeronym-generator/numeronym-generator.vue')['default']
|
||||
OtpCodeGeneratorAndValidator: typeof import('./src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue')['default']
|
||||
PasswordStrengthAnalyser: typeof import('./src/tools/password-strength-analyser/password-strength-analyser.vue')['default']
|
||||
|
|
15
package.json
15
package.json
|
@ -25,10 +25,10 @@
|
|||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && NODE_OPTIONS=--max_old_space_size=4096 vite build",
|
||||
"preview": "vite preview --port 5050",
|
||||
"test": "npm run test:unit",
|
||||
"test:unit": "vitest --environment jsdom",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:dev": "BASE_URL=http://localhost:5173 NO_WEB_SERVER=true playwright test",
|
||||
"test": "TZ=UTC npm run test:unit",
|
||||
"test:unit": "TZ=UTC vitest --environment jsdom",
|
||||
"test:e2e": "TZ=UTC playwright test",
|
||||
"test:e2e:dev": "TZ=UTC BASE_URL=http://localhost:5173 NO_WEB_SERVER=true playwright test",
|
||||
"coverage": "vitest run --coverage",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
||||
"lint": "eslint src --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --ignore-path .gitignore",
|
||||
|
@ -46,6 +46,7 @@
|
|||
"@tiptap/starter-kit": "2.1.6",
|
||||
"@tiptap/vue-3": "2.0.3",
|
||||
"@types/figlet": "^1.5.8",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/markdown-it": "^13.0.7",
|
||||
"@vicons/material": "^0.12.0",
|
||||
"@vicons/tabler": "^0.12.0",
|
||||
|
@ -56,12 +57,15 @@
|
|||
"change-case": "^4.1.2",
|
||||
"colord": "^2.9.3",
|
||||
"composerize-ts": "^0.6.2",
|
||||
"countries-and-timezones": "^3.7.2",
|
||||
"country-code-lookup": "^0.1.0",
|
||||
"cron-validator": "^1.3.1",
|
||||
"cronstrue": "^2.26.0",
|
||||
"crypto-js": "^4.1.1",
|
||||
"date-fns": "^2.29.3",
|
||||
"date-holidays": "^3.23.12",
|
||||
"dompurify": "^3.0.6",
|
||||
"duration-fns": "^3.0.2",
|
||||
"email-normalizer": "^1.0.0",
|
||||
"emojilib": "^3.0.10",
|
||||
"figlet": "^1.7.0",
|
||||
|
@ -75,6 +79,7 @@
|
|||
"jwt-decode": "^3.1.2",
|
||||
"libphonenumber-js": "^1.10.28",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.5.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"marked": "^10.0.0",
|
||||
"mathjs": "^11.9.1",
|
||||
|
@ -84,9 +89,11 @@
|
|||
"netmask": "^2.0.2",
|
||||
"node-forge": "^1.3.1",
|
||||
"oui-data": "^1.0.10",
|
||||
"parse-duration": "^1.1.0",
|
||||
"pdf-signature-reader": "^1.4.2",
|
||||
"pinia": "^2.0.34",
|
||||
"plausible-tracker": "^0.3.8",
|
||||
"pretty-ms": "^9.1.0",
|
||||
"qrcode": "^1.5.1",
|
||||
"randexp": "^0.5.3",
|
||||
"sql-formatter": "^13.0.0",
|
||||
|
|
14355
pnpm-lock.yaml
generated
14355
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
@ -128,7 +128,7 @@ function activateOption(option: PaletteOption) {
|
|||
<c-input-text ref="inputRef" v-model:value="searchPrompt" raw-text placeholder="Type to search a tool or a command..." autofocus clearable />
|
||||
|
||||
<div v-for="(options, category) in filteredSearchResult" :key="category">
|
||||
<div ml-3 mt-3 text-sm font-bold text-primary op-60>
|
||||
<div ml-3 mt-3 text-sm text-primary font-bold op-60>
|
||||
{{ category }}
|
||||
</div>
|
||||
<command-palette-option v-for="option in options" :key="option.name" :option="option" :selected="selectedOptionIndex === getOptionIndex(option)" @activated="activateOption" />
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
257
src/tools/days-calculator/days-calculator.service.test.ts
Normal file
257
src/tools/days-calculator/days-calculator.service.test.ts
Normal file
|
@ -0,0 +1,257 @@
|
|||
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 totalDiff1 = {
|
||||
totalDifference: {
|
||||
days: 30.416666666666668,
|
||||
hours: 730,
|
||||
minutes: 43800,
|
||||
months: 0.9811827956989247,
|
||||
seconds: 2628000,
|
||||
weeks: 4.345238095238095,
|
||||
years: 0.08333333333333333,
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
...totalDiff1,
|
||||
totalDifferenceFormatted: '30d 10h',
|
||||
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,
|
||||
totalDifference: {
|
||||
days: 29.69321758101852,
|
||||
hours: 712.6372219444445,
|
||||
minutes: 42758.233316666665,
|
||||
months: 0.9578457284199522,
|
||||
seconds: 2565493.999,
|
||||
weeks: 4.241888225859788,
|
||||
years: 0.08135128104388635,
|
||||
},
|
||||
totalDifferenceFormatted: '29d 16h 38m 13.9s',
|
||||
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,
|
||||
...totalDiff1,
|
||||
totalDifferenceFormatted: '30d 10h',
|
||||
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',
|
||||
...totalDiff1,
|
||||
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',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
183
src/tools/days-calculator/days-calculator.service.ts
Normal file
183
src/tools/days-calculator/days-calculator.service.ts
Normal file
|
@ -0,0 +1,183 @@
|
|||
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
|
||||
totalDifference: {
|
||||
years: number
|
||||
months: number
|
||||
weeks: number
|
||||
days: number
|
||||
hours: number
|
||||
minutes: number
|
||||
seconds: 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 totalDifferenceMinutes = endDateTime.diff(startDateTime, 'minutes').toObject().minutes || 0;
|
||||
const totalDifferenceHours = endDateTime.diff(startDateTime, 'hours').toObject().hours || 0;
|
||||
const totalDifferenceDays = endDateTime.diff(startDateTime, 'days').toObject().days || 0;
|
||||
const totalDifferenceWeeks = endDateTime.diff(startDateTime, 'weeks').toObject().weeks || 0;
|
||||
const totalDifferenceMonths = endDateTime.diff(startDateTime, 'months').toObject().months || 0;
|
||||
const totalDifferenceYears = endDateTime.diff(startDateTime, 'years').toObject().years || 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(),
|
||||
totalDifference: {
|
||||
years: totalDifferenceYears,
|
||||
months: totalDifferenceMonths,
|
||||
weeks: totalDifferenceWeeks,
|
||||
days: totalDifferenceDays,
|
||||
hours: totalDifferenceHours,
|
||||
minutes: totalDifferenceMinutes,
|
||||
seconds: 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 }));
|
||||
}
|
186
src/tools/days-calculator/days-calculator.vue
Normal file
186
src/tools/days-calculator/days-calculator.vue
Normal file
|
@ -0,0 +1,186 @@
|
|||
<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.totalDifference.seconds" />
|
||||
<input-copyable v-bind="inputProps" label="Total Difference Minutes" :value="resultDaysDiff.totalDifference.minutes" />
|
||||
<input-copyable v-bind="inputProps" label="Total Difference Hours" :value="resultDaysDiff.totalDifference.hours" />
|
||||
<input-copyable v-bind="inputProps" label="Total Difference Days" :value="resultDaysDiff.totalDifference.days" />
|
||||
<input-copyable v-bind="inputProps" label="Total Difference Weeks" :value="resultDaysDiff.totalDifference.weeks" />
|
||||
<input-copyable v-bind="inputProps" label="Total Difference Months" :value="resultDaysDiff.totalDifference.months" />
|
||||
<input-copyable v-bind="inputProps" label="Total Difference Years" :value="resultDaysDiff.totalDifference.years" />
|
||||
<input-copyable v-bind="inputProps" label="Total Difference" :value="resultDaysDiff.totalDifferenceFormatted" />
|
||||
<n-divider />
|
||||
<input-copyable v-bind="inputProps" label="Difference Seconds" :value="resultDaysDiff.differenceSeconds" />
|
||||
<input-copyable v-bind="inputProps" label="Difference " :value="resultDaysDiff.differenceFormatted" />
|
||||
<n-divider />
|
||||
<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" />
|
||||
<n-divider />
|
||||
<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" />
|
||||
<n-divider />
|
||||
<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'),
|
||||
});
|
|
@ -0,0 +1,371 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { computeDuration } from './duration-calculator.service';
|
||||
|
||||
const zeroResult = {
|
||||
errors: [],
|
||||
total: {
|
||||
days: 0,
|
||||
hours: 0,
|
||||
iso8601Duration: 'P0Y0M0DT0H0M0S',
|
||||
milliseconds: 0,
|
||||
minutes: 0,
|
||||
prettified: '0ms',
|
||||
prettifiedColonNotation: '0:00',
|
||||
prettifiedDaysColon: '00:00:00',
|
||||
prettifiedHoursColon: '00:00:00',
|
||||
prettifiedVerbose: '0 milliseconds',
|
||||
seconds: 0,
|
||||
weeks: 0,
|
||||
years: 0,
|
||||
},
|
||||
};
|
||||
|
||||
describe('duration-calculator', () => {
|
||||
describe('computeDuration', () => {
|
||||
it('should compute correct sum/values', () => {
|
||||
expect(computeDuration('')).to.deep.eq(zeroResult);
|
||||
expect(computeDuration('00:00:00')).to.deep.eq(zeroResult);
|
||||
expect(computeDuration('0h')).to.deep.eq(zeroResult);
|
||||
expect(computeDuration('0s')).to.deep.eq(zeroResult);
|
||||
expect(computeDuration('3600s')).to.deep.eq({
|
||||
errors: [],
|
||||
total: {
|
||||
days: 0.041666666666666664,
|
||||
hours: 1,
|
||||
iso8601Duration: 'P0Y0M0DT1H0M0S',
|
||||
milliseconds: 3600000,
|
||||
minutes: 60,
|
||||
prettified: '1h',
|
||||
prettifiedColonNotation: '1:00:00',
|
||||
prettifiedDaysColon: '01:00:00',
|
||||
prettifiedHoursColon: '01:00:00',
|
||||
prettifiedVerbose: '1 hour',
|
||||
seconds: 3600,
|
||||
weeks: 0.005952380952380952,
|
||||
years: 0.00011415525114155251,
|
||||
},
|
||||
});
|
||||
expect(computeDuration('1h 20m')).to.deep.eq({
|
||||
errors: [],
|
||||
total: {
|
||||
days: 0.05555555555555555,
|
||||
hours: 1.3333333333333333,
|
||||
iso8601Duration: 'P0Y0M0DT1H20M0S',
|
||||
milliseconds: 4800000,
|
||||
minutes: 80,
|
||||
prettified: '1h 20m',
|
||||
prettifiedColonNotation: '1:20:00',
|
||||
prettifiedDaysColon: '01:20:00',
|
||||
prettifiedHoursColon: '01:20:00',
|
||||
prettifiedVerbose: '1 hour 20 minutes',
|
||||
seconds: 4800,
|
||||
weeks: 0.007936507936507936,
|
||||
years: 0.00015220700152207003,
|
||||
},
|
||||
});
|
||||
expect(computeDuration('01:02:03')).to.deep.eq({
|
||||
errors: [],
|
||||
total: {
|
||||
days: 0.043090277777777776,
|
||||
hours: 1.0341666666666667,
|
||||
iso8601Duration: 'P0Y0M0DT1H2M3S',
|
||||
milliseconds: 3723000,
|
||||
minutes: 62.05,
|
||||
prettified: '1h 2m 3s',
|
||||
prettifiedColonNotation: '1:02:03',
|
||||
prettifiedDaysColon: '01:02:03',
|
||||
prettifiedHoursColon: '01:02:03',
|
||||
prettifiedVerbose: '1 hour 2 minutes 3 seconds',
|
||||
seconds: 3723,
|
||||
weeks: 0.006155753968253968,
|
||||
years: 0.00011805555555555556,
|
||||
},
|
||||
});
|
||||
expect(computeDuration('-01:02:03')).to.deep.eq({
|
||||
errors: [],
|
||||
total: {
|
||||
days: -0.043090277777777776,
|
||||
hours: -1.0341666666666667,
|
||||
iso8601Duration: 'P0Y0M0DT1H2M3S',
|
||||
milliseconds: -3723000,
|
||||
minutes: -62.05,
|
||||
prettified: '-1h 2m 3s',
|
||||
prettifiedColonNotation: '-1:02:03',
|
||||
prettifiedDaysColon: '-2:-3:-3',
|
||||
prettifiedHoursColon: '-2:-3:-3',
|
||||
prettifiedVerbose: '-1 hour 2 minutes 3 seconds',
|
||||
seconds: -3723,
|
||||
weeks: -0.006155753968253968,
|
||||
years: -0.00011805555555555556,
|
||||
},
|
||||
});
|
||||
expect(computeDuration('+01:02:05')).to.deep.eq({
|
||||
errors: [],
|
||||
total: {
|
||||
days: 0.04311342592592592,
|
||||
hours: 1.0347222222222223,
|
||||
iso8601Duration: 'P0Y0M0DT1H2M5S',
|
||||
milliseconds: 3725000,
|
||||
minutes: 62.083333333333336,
|
||||
prettified: '1h 2m 5s',
|
||||
prettifiedColonNotation: '1:02:05',
|
||||
prettifiedDaysColon: '01:02:05',
|
||||
prettifiedHoursColon: '01:02:05',
|
||||
prettifiedVerbose: '1 hour 2 minutes 5 seconds',
|
||||
seconds: 3725,
|
||||
weeks: 0.006159060846560847,
|
||||
years: 0.00011811897513952308,
|
||||
},
|
||||
});
|
||||
expect(computeDuration('25s\n+02:40:00.125\n-10s')).to.deep.eq({
|
||||
errors: [],
|
||||
total: {
|
||||
days: 0.11128616898148148,
|
||||
hours: 2.6708680555555557,
|
||||
iso8601Duration: 'P0Y0M0DT2H40M15.125S',
|
||||
milliseconds: 9615125,
|
||||
minutes: 160.25208333333333,
|
||||
prettified: '2h 40m 15s 125ms',
|
||||
prettifiedColonNotation: '2:40:15.1',
|
||||
prettifiedDaysColon: '02:40:15.125',
|
||||
prettifiedHoursColon: '02:40:15.125',
|
||||
prettifiedVerbose: '2 hours 40 minutes 15 seconds 125 milliseconds',
|
||||
seconds: 9615.125,
|
||||
weeks: 0.01589802414021164,
|
||||
years: 0.00030489361364789447,
|
||||
},
|
||||
});
|
||||
expect(computeDuration('3d 25s\n+00:40:00\n-10s')).to.deep.eq({
|
||||
errors: [],
|
||||
total: {
|
||||
days: 3.027951388888889,
|
||||
hours: 72.67083333333333,
|
||||
iso8601Duration: 'P0Y0M3DT0H40M15S',
|
||||
milliseconds: 261615000,
|
||||
minutes: 4360.25,
|
||||
prettified: '3d 40m 15s',
|
||||
prettifiedColonNotation: '3:00:40:15',
|
||||
prettifiedDaysColon: '3d 00:40:15',
|
||||
prettifiedHoursColon: '72:40:15',
|
||||
prettifiedVerbose: '3 days 40 minutes 15 seconds',
|
||||
seconds: 261615,
|
||||
weeks: 0.4325644841269841,
|
||||
years: 0.008295757229832572,
|
||||
},
|
||||
});
|
||||
expect(computeDuration('25s\n+12:40\n-10s')).to.deep.eq({
|
||||
errors: [],
|
||||
total: {
|
||||
days: 0.5279513888888889,
|
||||
hours: 12.670833333333333,
|
||||
iso8601Duration: 'P0Y0M0DT12H40M15S',
|
||||
milliseconds: 45615000,
|
||||
minutes: 760.25,
|
||||
prettified: '12h 40m 15s',
|
||||
prettifiedColonNotation: '12:40:15',
|
||||
prettifiedDaysColon: '12:40:15',
|
||||
prettifiedHoursColon: '12:40:15',
|
||||
prettifiedVerbose: '12 hours 40 minutes 15 seconds',
|
||||
seconds: 45615,
|
||||
weeks: 0.07542162698412698,
|
||||
years: 0.0014464421613394217,
|
||||
},
|
||||
});
|
||||
|
||||
expect(computeDuration('P4DT12H20M20.3S')).to.deep.eq({
|
||||
errors: [],
|
||||
total: {
|
||||
days: 4.514123842592593,
|
||||
hours: 108.33897222222222,
|
||||
iso8601Duration: 'P0Y0M4DT12H20M20.3S',
|
||||
milliseconds: 390020300,
|
||||
minutes: 6500.338333333333,
|
||||
prettified: '4d 12h 20m 20s 300ms',
|
||||
prettifiedColonNotation: '4:12:20:20.3',
|
||||
prettifiedDaysColon: '4d 12:20:20.300',
|
||||
prettifiedHoursColon: '108:20:20.300',
|
||||
prettifiedVerbose: '4 days 12 hours 20 minutes 20 seconds 300 milliseconds',
|
||||
seconds: 390020.3,
|
||||
weeks: 0.6448748346560846,
|
||||
years: 0.012367462582445459,
|
||||
},
|
||||
});
|
||||
expect(computeDuration('25s\n+PT20H\n-10s')).to.deep.eq({
|
||||
errors: [],
|
||||
total: {
|
||||
days: 0.8335069444444444,
|
||||
hours: 20.004166666666666,
|
||||
iso8601Duration: 'P0Y0M0DT20H0M15S',
|
||||
milliseconds: 72015000,
|
||||
minutes: 1200.25,
|
||||
prettified: '20h 15s',
|
||||
prettifiedColonNotation: '20:00:15',
|
||||
prettifiedDaysColon: '20:00:15',
|
||||
prettifiedHoursColon: '20:00:15',
|
||||
prettifiedVerbose: '20 hours 15 seconds',
|
||||
seconds: 72015,
|
||||
weeks: 0.11907242063492063,
|
||||
years: 0.0022835806697108067,
|
||||
},
|
||||
});
|
||||
});
|
||||
it('should report invalid lines', () => {
|
||||
expect(computeDuration('azerr')).to.deep.eq({
|
||||
errors: [
|
||||
'azerr',
|
||||
],
|
||||
total: {
|
||||
days: 0,
|
||||
hours: 0,
|
||||
iso8601Duration: 'P0Y0M0DT0H0M0S',
|
||||
milliseconds: 0,
|
||||
minutes: 0,
|
||||
prettified: '0ms',
|
||||
prettifiedColonNotation: '0:00',
|
||||
prettifiedDaysColon: '00:00:00',
|
||||
prettifiedHoursColon: '00:00:00',
|
||||
prettifiedVerbose: '0 milliseconds',
|
||||
seconds: 0,
|
||||
weeks: 0,
|
||||
years: 0,
|
||||
},
|
||||
});
|
||||
expect(computeDuration('25s\ner\n-10s')).to.deep.eq({
|
||||
errors: [
|
||||
'er',
|
||||
],
|
||||
total: {
|
||||
days: 0.00017361111111111112,
|
||||
hours: 0.004166666666666667,
|
||||
iso8601Duration: 'P0Y0M0DT0H0M15S',
|
||||
milliseconds: 15000,
|
||||
minutes: 0.25,
|
||||
prettified: '15s',
|
||||
prettifiedColonNotation: '0:15',
|
||||
prettifiedDaysColon: '00:00:15',
|
||||
prettifiedHoursColon: '00:00:15',
|
||||
prettifiedVerbose: '15 seconds',
|
||||
seconds: 15,
|
||||
weeks: 0.0000248015873015873,
|
||||
years: 4.756468797564688e-7,
|
||||
},
|
||||
});
|
||||
expect(computeDuration('25s\n+00:40:00\ner')).to.deep.eq({
|
||||
errors: [
|
||||
'er',
|
||||
],
|
||||
total: {
|
||||
days: 0.02806712962962963,
|
||||
hours: 0.6736111111111112,
|
||||
iso8601Duration: 'P0Y0M0DT0H40M25S',
|
||||
milliseconds: 2425000,
|
||||
minutes: 40.416666666666664,
|
||||
prettified: '40m 25s',
|
||||
prettifiedColonNotation: '40:25',
|
||||
prettifiedDaysColon: '00:40:25',
|
||||
prettifiedHoursColon: '00:40:25',
|
||||
prettifiedVerbose: '40 minutes 25 seconds',
|
||||
seconds: 2425,
|
||||
weeks: 0.004009589947089947,
|
||||
years: 0.00007689624556062913,
|
||||
},
|
||||
});
|
||||
expect(computeDuration('ty\n+12:40\n-10s')).to.deep.eq({
|
||||
errors: [
|
||||
'ty',
|
||||
],
|
||||
total: {
|
||||
days: 0.5276620370370371,
|
||||
hours: 12.66388888888889,
|
||||
iso8601Duration: 'P0Y0M0DT12H39M50S',
|
||||
milliseconds: 45590000,
|
||||
minutes: 759.8333333333334,
|
||||
prettified: '12h 39m 50s',
|
||||
prettifiedColonNotation: '12:39:50',
|
||||
prettifiedDaysColon: '12:39:50',
|
||||
prettifiedHoursColon: '12:39:50',
|
||||
prettifiedVerbose: '12 hours 39 minutes 50 seconds',
|
||||
seconds: 45590,
|
||||
weeks: 0.075380291005291,
|
||||
years: 0.0014456494165398274,
|
||||
},
|
||||
});
|
||||
});
|
||||
it('support comment lines (#)', () => {
|
||||
expect(computeDuration('25s\n # comment\n-10s')).to.deep.eq({
|
||||
errors: [],
|
||||
total: {
|
||||
days: 0.00017361111111111112,
|
||||
hours: 0.004166666666666667,
|
||||
iso8601Duration: 'P0Y0M0DT0H0M15S',
|
||||
milliseconds: 15000,
|
||||
minutes: 0.25,
|
||||
prettified: '15s',
|
||||
prettifiedColonNotation: '0:15',
|
||||
prettifiedDaysColon: '00:00:15',
|
||||
prettifiedHoursColon: '00:00:15',
|
||||
prettifiedVerbose: '15 seconds',
|
||||
seconds: 15,
|
||||
weeks: 0.0000248015873015873,
|
||||
years: 4.756468797564688e-7,
|
||||
},
|
||||
});
|
||||
});
|
||||
it('support timespan format d.hh:mm:ss.fff', () => {
|
||||
expect(computeDuration('3.12:12:12\n-1.12:12:12\n+0:0:0.125')).to.deep.eq({
|
||||
errors: [],
|
||||
total: {
|
||||
days: 2.000001446759259,
|
||||
hours: 48.000034722222225,
|
||||
iso8601Duration: 'P0Y0M2DT0H0M0.125S',
|
||||
milliseconds: 172800125,
|
||||
minutes: 2880.0020833333333,
|
||||
prettified: '2d 125ms',
|
||||
prettifiedColonNotation: '2:00:00:00.1',
|
||||
prettifiedDaysColon: '2d 00:00:00.125',
|
||||
prettifiedHoursColon: '48:00:00.125',
|
||||
prettifiedVerbose: '2 days 125 milliseconds',
|
||||
seconds: 172800.125,
|
||||
weeks: 0.2857144923941799,
|
||||
years: 0.005479456018518519,
|
||||
},
|
||||
});
|
||||
expect(computeDuration('12:12:12.1')).to.deep.eq({
|
||||
errors: [],
|
||||
total: {
|
||||
days: 0.5084733796296297,
|
||||
hours: 12.20336111111111,
|
||||
iso8601Duration: 'P0Y0M0DT12H12M12.1S',
|
||||
milliseconds: 43932100,
|
||||
minutes: 732.2016666666667,
|
||||
prettified: '12h 12m 12s 100ms',
|
||||
prettifiedColonNotation: '12:12:12.1',
|
||||
prettifiedDaysColon: '12:12:12.100',
|
||||
prettifiedHoursColon: '12:12:12.100',
|
||||
prettifiedVerbose: '12 hours 12 minutes 12 seconds 100 milliseconds',
|
||||
seconds: 43932.1,
|
||||
weeks: 0.07263905423280423,
|
||||
years: 0.0013930777524099442,
|
||||
},
|
||||
});
|
||||
expect(computeDuration('12:12:12.12')).to.deep.eq({
|
||||
errors: [],
|
||||
total: {
|
||||
days: 0.5084736111111111,
|
||||
hours: 12.203366666666666,
|
||||
iso8601Duration: 'P0Y0M0DT12H12M12.12S',
|
||||
milliseconds: 43932120,
|
||||
minutes: 732.202,
|
||||
prettified: '12h 12m 12s 120ms',
|
||||
prettifiedColonNotation: '12:12:12.1',
|
||||
prettifiedDaysColon: '12:12:12.120',
|
||||
prettifiedHoursColon: '12:12:12.120',
|
||||
prettifiedVerbose: '12 hours 12 minutes 12 seconds 120 milliseconds',
|
||||
seconds: 43932.12,
|
||||
weeks: 0.0726390873015873,
|
||||
years: 0.001393078386605784,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
132
src/tools/duration-calculator/duration-calculator.service.ts
Normal file
132
src/tools/duration-calculator/duration-calculator.service.ts
Normal file
|
@ -0,0 +1,132 @@
|
|||
import parse from 'parse-duration';
|
||||
import prettyMilliseconds from 'pretty-ms';
|
||||
import { formatISODuration, intervalToDuration } from 'date-fns';
|
||||
import * as iso8601Duration from 'duration-fns';
|
||||
|
||||
interface ConvertedDuration {
|
||||
prettified: string
|
||||
prettifiedVerbose: string
|
||||
prettifiedColonNotation: string
|
||||
prettifiedDaysColon: string
|
||||
prettifiedHoursColon: string
|
||||
iso8601Duration: string
|
||||
milliseconds: number
|
||||
seconds: number
|
||||
minutes: number
|
||||
hours: number
|
||||
days: number
|
||||
weeks: number
|
||||
years: number
|
||||
}
|
||||
|
||||
interface DurationLine {
|
||||
rawLine: string
|
||||
cleanedDuration: string
|
||||
sign: number
|
||||
durationMS: number | null
|
||||
isValid: boolean
|
||||
}
|
||||
|
||||
export function computeDuration(s: string): {
|
||||
total: ConvertedDuration
|
||||
errors: string[]
|
||||
} {
|
||||
const lines: DurationLine[] = s.split('\n').filter(l => l && !/^\s*#/.test(l)).map((l) => {
|
||||
const isNeg = /^\s*-/.test(l);
|
||||
const cleanedDuration = l.replace(/^\s*[+-]\s*/, '').replace(/\s*#.*$/, ''); // NOSONAR
|
||||
const durationMS = convertDurationMS(cleanedDuration);
|
||||
return {
|
||||
rawLine: l,
|
||||
cleanedDuration,
|
||||
sign: isNeg ? -1 : 1,
|
||||
durationMS,
|
||||
isValid: durationMS !== null,
|
||||
};
|
||||
});
|
||||
|
||||
const sumMS = lines.map(l => ({ durationMS: l.durationMS || 0, sign: l.sign })).reduce(
|
||||
(prev, curr) => ({
|
||||
durationMS: prev.durationMS + curr.durationMS * curr.sign,
|
||||
sign: 1,
|
||||
}),
|
||||
{
|
||||
sign: 1,
|
||||
durationMS: 0,
|
||||
});
|
||||
|
||||
return {
|
||||
total: prepareDurationResult(sumMS.durationMS),
|
||||
errors: lines.filter(l => !l.isValid).map(l => l.rawLine),
|
||||
};
|
||||
}
|
||||
|
||||
function convertDurationMS(s: string): number | null {
|
||||
const hoursHandled = s.trim().replace(/^(?:(\d+)\.)?(\d+):(\d+)(?::(\d+)(?:\.(\d+))?)?$/g,
|
||||
(_, d, h, m, s, ms) => {
|
||||
const timeArr: string[] = [];
|
||||
const addPart = (part: string, unit: string) => {
|
||||
const num = Number.parseInt(part, 10);
|
||||
if (Number.isNaN(num)) {
|
||||
return;
|
||||
}
|
||||
|
||||
timeArr.push(`${num}${unit}`);
|
||||
};
|
||||
addPart(d, 'd');
|
||||
addPart(h, 'h');
|
||||
addPart(m, 'm');
|
||||
addPart(s, 's');
|
||||
addPart(ms?.padEnd(3, '0'), 'ms');
|
||||
return timeArr.join(' ');
|
||||
});
|
||||
if (!hoursHandled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
return iso8601Duration.toMilliseconds(iso8601Duration.parse(hoursHandled));
|
||||
}
|
||||
catch (_) {
|
||||
const result = parse(hoursHandled);
|
||||
if (typeof result === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
function prepareDurationResult(durationMS: number): ConvertedDuration {
|
||||
const dateFnsDuration = intervalToDuration({ start: 0, end: durationMS });
|
||||
dateFnsDuration.seconds = (dateFnsDuration.seconds || 0) + (durationMS % 1000) / 1000;
|
||||
return {
|
||||
prettified: prettyMilliseconds(durationMS, { formatSubMilliseconds: true }),
|
||||
prettifiedVerbose: prettyMilliseconds(durationMS, { verbose: true, formatSubMilliseconds: true }),
|
||||
prettifiedColonNotation: prettyMilliseconds(durationMS, { colonNotation: true, formatSubMilliseconds: true }),
|
||||
prettifiedDaysColon: hhmmss(durationMS, true),
|
||||
prettifiedHoursColon: hhmmss(durationMS, false),
|
||||
iso8601Duration: formatISODuration(dateFnsDuration),
|
||||
milliseconds: durationMS,
|
||||
seconds: durationMS / 1000,
|
||||
minutes: durationMS / (1000 * 60),
|
||||
hours: durationMS / (1000 * 3600),
|
||||
days: durationMS / (1000 * 86400),
|
||||
weeks: durationMS / (1000 * 86400 * 7),
|
||||
years: durationMS / (1000 * 86400 * 365),
|
||||
};
|
||||
}
|
||||
|
||||
function hhmmss(milliseconds: number, days: boolean) {
|
||||
const padNumber = (n: number) => n.toString().padStart(2, '0');
|
||||
const ms = milliseconds % 1000;
|
||||
const seconds = milliseconds / 1000;
|
||||
let h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor(seconds % 3600 / 60);
|
||||
const s = Math.floor(seconds % 3600 % 60);
|
||||
let d = 0;
|
||||
if (days) {
|
||||
d = Math.floor(h / 24);
|
||||
h = h % 24;
|
||||
}
|
||||
const formatted_d = d > 0 ? `${d}d ` : '';
|
||||
const formatted_ms = ms > 0 ? `.${ms}` : '';
|
||||
return `${formatted_d}${padNumber(h)}:${padNumber(m)}:${padNumber(s)}${formatted_ms}`;
|
||||
}
|
43
src/tools/duration-calculator/duration-calculator.vue
Normal file
43
src/tools/duration-calculator/duration-calculator.vue
Normal file
|
@ -0,0 +1,43 @@
|
|||
<script setup lang="ts">
|
||||
import { computeDuration } from './duration-calculator.service';
|
||||
|
||||
const inputDurations = ref('');
|
||||
const result = computed(() => computeDuration(inputDurations.value));
|
||||
const errors = computed(() => result.value.errors.join('\n'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<n-divider />
|
||||
|
||||
<c-card title="Total">
|
||||
<input-copyable label="Prettified" :value="result.total.prettified" />
|
||||
<input-copyable label="Prettified (full)" :value="result.total.prettifiedVerbose" />
|
||||
<input-copyable label="Prettified (colon)" :value="result.total.prettifiedColonNotation" />
|
||||
<input-copyable label="Prettified (days)" :value="result.total.prettifiedDaysColon" />
|
||||
<input-copyable label="Prettified (hours)" :value="result.total.prettifiedHoursColon" />
|
||||
<input-copyable label="Prettified (ISO8601)" :value="result.total.iso8601Duration" />
|
||||
<input-copyable label="Milliseconds" :value="result.total.milliseconds" />
|
||||
<input-copyable label="Seconds" :value="result.total.seconds" />
|
||||
<input-copyable label="Minutes" :value="result.total.minutes" />
|
||||
<input-copyable label="Hours" :value="result.total.hours" />
|
||||
<input-copyable label="Days" :value="result.total.days" />
|
||||
<input-copyable label="Weeks" :value="result.total.weeks" />
|
||||
<input-copyable label="Years" :value="result.total.years" />
|
||||
</c-card>
|
||||
|
||||
<c-card title="Lines errors" mb-2>
|
||||
<textarea-copyable :value="errors" />
|
||||
</c-card>
|
||||
</div>
|
||||
</template>
|
12
src/tools/duration-calculator/index.ts
Normal file
12
src/tools/duration-calculator/index.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { CalendarTime } from '@vicons/tabler';
|
||||
import { defineTool } from '../tool';
|
||||
|
||||
export const tool = defineTool({
|
||||
name: 'Duration Calculator',
|
||||
path: '/duration-calculator',
|
||||
description: 'Calculate/parse durations',
|
||||
keywords: ['duration', 'iso', '8601', 'time', 'calculator'],
|
||||
component: () => import('./duration-calculator.vue'),
|
||||
icon: CalendarTime,
|
||||
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 basicAuthGenerator } from './basic-auth-generator';
|
||||
import { tool as emailNormalizer } from './email-normalizer';
|
||||
|
||||
import { tool as asciiTextDrawer } from './ascii-text-drawer';
|
||||
|
||||
import { tool as daysCalculator } from './days-calculator';
|
||||
import { tool as dateDurationCalculator } from './date-duration-calculator';
|
||||
import { tool as textToUnicode } from './text-to-unicode';
|
||||
import { tool as safelinkDecoder } from './safelink-decoder';
|
||||
import { tool as xmlToJson } from './xml-to-json';
|
||||
|
@ -12,6 +12,7 @@ import { tool as jsonToXml } from './json-to-xml';
|
|||
import { tool as regexTester } from './regex-tester';
|
||||
import { tool as regexMemo } from './regex-memo';
|
||||
import { tool as markdownToHtml } from './markdown-to-html';
|
||||
import { tool as durationCalculator } from './duration-calculator';
|
||||
import { tool as pdfSignatureChecker } from './pdf-signature-checker';
|
||||
import { tool as numeronymGenerator } from './numeronym-generator';
|
||||
import { tool as macAddressGenerator } from './mac-address-generator';
|
||||
|
@ -172,7 +173,14 @@ export const toolsByCategory: ToolCategory[] = [
|
|||
},
|
||||
{
|
||||
name: 'Measurement',
|
||||
components: [chronometer, temperatureConverter, benchmarkBuilder],
|
||||
components: [
|
||||
chronometer,
|
||||
temperatureConverter,
|
||||
daysCalculator,
|
||||
durationCalculator,
|
||||
dateDurationCalculator,
|
||||
benchmarkBuilder,
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Text',
|
||||
|
|
|
@ -151,7 +151,7 @@ function onSearchInput() {
|
|||
>
|
||||
<div flex-1 truncate>
|
||||
<slot name="displayed-value">
|
||||
<input v-if="searchable && isOpen" ref="searchInputRef" v-model="searchQuery" type="text" placeholder="Search..." class="search-input" w-full lh-normal color-current @input="onSearchInput">
|
||||
<input v-if="searchable && isOpen" ref="searchInputRef" v-model="searchQuery" type="text" placeholder="Search..." class="search-input" w-full color-current lh-normal @input="onSearchInput">
|
||||
<span v-else-if="selectedOption" lh-normal>
|
||||
{{ selectedOption.label }}
|
||||
</span>
|
||||
|
|
|
@ -39,7 +39,7 @@ const headers = computed(() => {
|
|||
<template>
|
||||
<div class="relative overflow-x-auto rounded">
|
||||
<table class="w-full border-collapse text-left text-sm text-gray-500 dark:text-gray-400" role="table" :aria-label="description">
|
||||
<thead v-if="!hideHeaders" class="bg-#ffffff uppercase text-gray-700 dark:bg-#333333 dark:text-gray-400" border-b="1px solid dark:transparent #efeff5">
|
||||
<thead v-if="!hideHeaders" class="bg-#ffffff text-gray-700 uppercase dark:bg-#333333 dark:text-gray-400" border-b="1px solid dark:transparent #efeff5">
|
||||
<tr>
|
||||
<th v-for="header in headers" :key="header.key" scope="col" class="px-6 py-3 text-xs">
|
||||
{{ header.label }}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue