From 2384d3ba4499544a10095055c8b3164b981f5da3 Mon Sep 17 00:00:00 2001 From: sharevb Date: Sat, 28 Sep 2024 11:09:35 +0200 Subject: [PATCH 1/8] feat(new tool): Duration Calculator Fix #1037 and #1161 --- .../duration-calculator.service.test.ts | 313 ++++++++++++++++++ .../duration-calculator.service.ts | 127 +++++++ .../duration-calculator.vue | 43 +++ src/tools/duration-calculator/index.ts | 12 + src/tools/index.ts | 8 +- 5 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 src/tools/duration-calculator/duration-calculator.service.test.ts create mode 100644 src/tools/duration-calculator/duration-calculator.service.ts create mode 100644 src/tools/duration-calculator/duration-calculator.vue create mode 100644 src/tools/duration-calculator/index.ts diff --git a/src/tools/duration-calculator/duration-calculator.service.test.ts b/src/tools/duration-calculator/duration-calculator.service.test.ts new file mode 100644 index 00000000..9035587a --- /dev/null +++ b/src/tools/duration-calculator/duration-calculator.service.test.ts @@ -0,0 +1,313 @@ +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('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: 'P0Y0M0DT2H40M15S', + milliseconds: 9615125, + minutes: 160.25208333333333, + prettified: '2h 40m 15.1s', + prettifiedColonNotation: '2:40:15.1', + prettifiedDaysColon: '02:40:15.125', + prettifiedHoursColon: '02:40:15.125', + prettifiedVerbose: '2 hours 40 minutes 15.1 seconds', + 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: 0.5138891238425926, + hours: 12.333338972222222, + iso8601Duration: 'P0Y0M0DT12H20M0S', + milliseconds: 44400020.3, + minutes: 740.0003383333333, + prettified: '12h 20m', + prettifiedColonNotation: '12:20:00', + prettifiedDaysColon: '12:20:00.20.299999997019768', + prettifiedHoursColon: '12:20:00.20.299999997019768', + prettifiedVerbose: '12 hours 20 minutes', + seconds: 44400.0203, + weeks: 0.07341273197751322, + years: 0.0014079154077879248, + }, + }); + 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, + }, + }); + }); + }); +}); diff --git a/src/tools/duration-calculator/duration-calculator.service.ts b/src/tools/duration-calculator/duration-calculator.service.ts new file mode 100644 index 00000000..395e1761 --- /dev/null +++ b/src/tools/duration-calculator/duration-calculator.service.ts @@ -0,0 +1,127 @@ +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 | undefined + 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*/, ''); + const durationMS = convertDurationMS(cleanedDuration); + return { + rawLine: l, + cleanedDuration, + sign: isNeg ? -1 : 1, + durationMS, + isValid: !(typeof durationMS === 'undefined'), + }; + }); + + 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 | undefined { + const hoursHandled = s.replace(/\b(\d+):(\d+)(?::(\d+)(?:\.(\d+))?)?/g, (_, 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(h, 'h'); + addPart(m, 'm'); + addPart(s, 's'); + addPart(ms, 'ms'); + return timeArr.join(' '); + }); + if (!hoursHandled) { + return 0; + } + + let parsedDuration = parse(hoursHandled); + if (parsedDuration !== 0 && !parsedDuration) { + try { + parsedDuration = iso8601Duration.toMilliseconds(iso8601Duration.parse(hoursHandled)); + } + catch (_) { + return undefined; + } + } + return parsedDuration; +} +function prepareDurationResult(durationMS: any): ConvertedDuration { + const dateFnsDuration = intervalToDuration({ start: 0, end: durationMS }); + return { + prettified: prettyMilliseconds(durationMS), + prettifiedVerbose: prettyMilliseconds(durationMS, { verbose: true }), + prettifiedColonNotation: prettyMilliseconds(durationMS, { colonNotation: 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; + } + return `${d > 0 ? `${d}d ` : ''}${padNumber(h)}:${padNumber(m)}:${padNumber(s)}${ms > 0 ? `.${ms}` : ''}`; +} diff --git a/src/tools/duration-calculator/duration-calculator.vue b/src/tools/duration-calculator/duration-calculator.vue new file mode 100644 index 00000000..ba35b7fb --- /dev/null +++ b/src/tools/duration-calculator/duration-calculator.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/tools/duration-calculator/index.ts b/src/tools/duration-calculator/index.ts new file mode 100644 index 00000000..0d2c7fed --- /dev/null +++ b/src/tools/duration-calculator/index.ts @@ -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'), +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index 52bdf8e3..a55143a3 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,6 +1,7 @@ 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 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'; @@ -151,7 +152,12 @@ export const toolsByCategory: ToolCategory[] = [ }, { name: 'Measurement', - components: [chronometer, temperatureConverter, benchmarkBuilder], + components: [ + chronometer, + temperatureConverter, + durationCalculator, + benchmarkBuilder, + ], }, { name: 'Text', From 5cd4253783e3d91d39a689f975897e4b1e54093b Mon Sep 17 00:00:00 2001 From: sharevb Date: Sat, 28 Sep 2024 11:09:35 +0200 Subject: [PATCH 2/8] feat(new tool): Duration Calculator Fix #1037 and #1161 --- components.d.ts | 33 +- package.json | 3 + pnpm-lock.yaml | 46 ++- .../duration-calculator.service.test.ts | 313 ++++++++++++++++++ .../duration-calculator.service.ts | 127 +++++++ .../duration-calculator.vue | 43 +++ src/tools/duration-calculator/index.ts | 12 + src/tools/index.ts | 8 +- 8 files changed, 545 insertions(+), 40 deletions(-) create mode 100644 src/tools/duration-calculator/duration-calculator.service.test.ts create mode 100644 src/tools/duration-calculator/duration-calculator.service.ts create mode 100644 src/tools/duration-calculator/duration-calculator.vue create mode 100644 src/tools/duration-calculator/index.ts diff --git a/components.d.ts b/components.d.ts index d034fc78..29dccdc1 100644 --- a/components.d.ts +++ b/components.d.ts @@ -69,6 +69,7 @@ declare module '@vue/runtime-core' { 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'] EmojiCard: typeof import('./src/tools/emoji-picker/emoji-card.vue')['default'] @@ -88,29 +89,17 @@ declare module '@vue/runtime-core' { HttpStatusCodes: typeof import('./src/tools/http-status-codes/http-status-codes.vue')['default'] IbanValidatorAndParser: typeof import('./src/tools/iban-validator-and-parser/iban-validator-and-parser.vue')['default'] 'IconMdi:brushVariant': typeof import('~icons/mdi/brush-variant')['default'] - 'IconMdi:contentCopy': typeof import('~icons/mdi/content-copy')['default'] 'IconMdi:kettleSteamOutline': typeof import('~icons/mdi/kettle-steam-outline')['default'] - IconMdiArrowDown: typeof import('~icons/mdi/arrow-down')['default'] - IconMdiArrowRight: typeof import('~icons/mdi/arrow-right')['default'] - IconMdiArrowRightBottom: typeof import('~icons/mdi/arrow-right-bottom')['default'] - IconMdiCamera: typeof import('~icons/mdi/camera')['default'] IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default'] IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default'] IconMdiClose: typeof import('~icons/mdi/close')['default'] IconMdiContentCopy: typeof import('~icons/mdi/content-copy')['default'] - IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default'] - IconMdiDownload: typeof import('~icons/mdi/download')['default'] IconMdiEye: typeof import('~icons/mdi/eye')['default'] IconMdiEyeOff: typeof import('~icons/mdi/eye-off')['default'] IconMdiHeart: typeof import('~icons/mdi/heart')['default'] - IconMdiPause: typeof import('~icons/mdi/pause')['default'] - IconMdiPlay: typeof import('~icons/mdi/play')['default'] - IconMdiRecord: typeof import('~icons/mdi/record')['default'] - IconMdiRefresh: typeof import('~icons/mdi/refresh')['default'] IconMdiSearch: typeof import('~icons/mdi/search')['default'] IconMdiTranslate: typeof import('~icons/mdi/translate')['default'] IconMdiTriangleDown: typeof import('~icons/mdi/triangle-down')['default'] - IconMdiVideo: typeof import('~icons/mdi/video')['default'] InputCopyable: typeof import('./src/components/InputCopyable.vue')['default'] IntegerBaseConverter: typeof import('./src/tools/integer-base-converter/integer-base-converter.vue')['default'] Ipv4AddressConverter: typeof import('./src/tools/ipv4-address-converter/ipv4-address-converter.vue')['default'] @@ -137,42 +126,24 @@ declare module '@vue/runtime-core' { MenuLayout: typeof import('./src/components/MenuLayout.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'] - NAlert: typeof import('naive-ui')['NAlert'] NavbarButtons: typeof import('./src/components/NavbarButtons.vue')['default'] - NCheckbox: typeof import('naive-ui')['NCheckbox'] NCode: typeof import('naive-ui')['NCode'] NCollapseTransition: typeof import('naive-ui')['NCollapseTransition'] - NColorPicker: typeof import('naive-ui')['NColorPicker'] NConfigProvider: typeof import('naive-ui')['NConfigProvider'] - NDatePicker: typeof import('naive-ui')['NDatePicker'] NDivider: typeof import('naive-ui')['NDivider'] - NDynamicInput: typeof import('naive-ui')['NDynamicInput'] NEllipsis: typeof import('naive-ui')['NEllipsis'] - NForm: typeof import('naive-ui')['NForm'] - NFormItem: typeof import('naive-ui')['NFormItem'] NGi: typeof import('naive-ui')['NGi'] NGrid: typeof import('naive-ui')['NGrid'] NH1: typeof import('naive-ui')['NH1'] - NH2: typeof import('naive-ui')['NH2'] NH3: typeof import('naive-ui')['NH3'] NIcon: typeof import('naive-ui')['NIcon'] - NImage: typeof import('naive-ui')['NImage'] - NInputGroup: typeof import('naive-ui')['NInputGroup'] - NInputGroupLabel: typeof import('naive-ui')['NInputGroupLabel'] - NInputNumber: typeof import('naive-ui')['NInputNumber'] NLayout: typeof import('naive-ui')['NLayout'] NLayoutSider: typeof import('naive-ui')['NLayoutSider'] NMenu: typeof import('naive-ui')['NMenu'] - NProgress: typeof import('naive-ui')['NProgress'] + NP: typeof import('naive-ui')['NP'] NScrollbar: typeof import('naive-ui')['NScrollbar'] - NSlider: typeof import('naive-ui')['NSlider'] - NStatistic: typeof import('naive-ui')['NStatistic'] - NSwitch: typeof import('naive-ui')['NSwitch'] - NTable: typeof import('naive-ui')['NTable'] NTag: typeof import('naive-ui')['NTag'] NumeronymGenerator: typeof import('./src/tools/numeronym-generator/numeronym-generator.vue')['default'] - NUpload: typeof import('naive-ui')['NUpload'] - NUploadDragger: typeof import('naive-ui')['NUploadDragger'] 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'] PdfSignatureChecker: typeof import('./src/tools/pdf-signature-checker/pdf-signature-checker.vue')['default'] diff --git a/package.json b/package.json index ffa7bd4e..520ffec4 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "crypto-js": "^4.1.1", "date-fns": "^2.29.3", "dompurify": "^3.0.6", + "duration-fns": "^3.0.2", "emojilib": "^3.0.10", "figue": "^1.2.0", "fuse.js": "^6.6.2", @@ -74,9 +75,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", "sql-formatter": "^13.0.0", "ua-parser-js": "^1.0.35", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfacabd4..6b7cf98e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ dependencies: dompurify: specifier: ^3.0.6 version: 3.0.6 + duration-fns: + specifier: ^3.0.2 + version: 3.0.2 emojilib: specifier: ^3.0.10 version: 3.0.10 @@ -122,6 +125,9 @@ dependencies: oui-data: specifier: ^1.0.10 version: 1.0.10 + parse-duration: + specifier: ^1.1.0 + version: 1.1.0 pdf-signature-reader: specifier: ^1.4.2 version: 1.4.2 @@ -131,6 +137,9 @@ dependencies: plausible-tracker: specifier: ^0.3.8 version: 0.3.8 + pretty-ms: + specifier: ^9.1.0 + version: 9.1.0 qrcode: specifier: ^1.5.1 version: 1.5.1 @@ -3374,7 +3383,7 @@ packages: dependencies: '@unhead/dom': 0.5.1 '@unhead/schema': 0.5.1 - '@vueuse/shared': 10.6.1(vue@3.3.4) + '@vueuse/shared': 11.1.0(vue@3.3.4) unhead: 0.5.1 vue: 3.3.4 transitivePeerDependencies: @@ -4016,10 +4025,10 @@ packages: - vue dev: false - /@vueuse/shared@10.6.1(vue@3.3.4): - resolution: {integrity: sha512-TECVDTIedFlL0NUfHWncf3zF9Gc4VfdxfQc8JFwoVZQmxpONhLxFrlm0eHQeidHj4rdTPL3KXJa0TZCk1wnc5Q==} + /@vueuse/shared@11.1.0(vue@3.3.4): + resolution: {integrity: sha512-YUtIpY122q7osj+zsNMFAfMTubGz0sn5QzE5gPzAIiCmtt2ha3uQUY1+JPyL4gRCTsLPX82Y9brNbo/aqlA91w==} dependencies: - vue-demi: 0.14.6(vue@3.3.4) + vue-demi: 0.14.10(vue@3.3.4) transitivePeerDependencies: - '@vue/composition-api' - vue @@ -4969,6 +4978,10 @@ packages: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} dev: true + /duration-fns@3.0.2: + resolution: {integrity: sha512-w82IXh/6aWNHFA0qlQazJYJrZTWieTItuuGTE7YX4cxPaZTWhmVImbsBBiMK1/OhGDgiinuCpJoSFILYLDSKDg==} + dev: false + /editorconfig@0.15.3: resolution: {integrity: sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==} dependencies: @@ -7302,6 +7315,10 @@ packages: callsites: 3.1.0 dev: true + /parse-duration@1.1.0: + resolution: {integrity: sha512-z6t9dvSJYaPoQq7quMzdEagSFtpGu+utzHqqxmpVWNNZRIXnvqyCvn9XsTdh7c/w0Bqmdz3RB3YnRaKtpRtEXQ==} + dev: false + /parse-entities@2.0.0: resolution: {integrity: sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==} dependencies: @@ -7323,6 +7340,11 @@ packages: lines-and-columns: 1.2.4 dev: true + /parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + dev: false + /parse-node-version@1.0.1: resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} engines: {node: '>= 0.10'} @@ -7535,6 +7557,13 @@ packages: react-is: 18.2.0 dev: true + /pretty-ms@9.1.0: + resolution: {integrity: sha512-o1piW0n3tgKIKCwk2vpM/vOV13zjJzvP37Ioze54YlTHE06m4tjEbzg9WsKkvTuyYln2DHjo5pY4qrZGI0otpw==} + engines: {node: '>=18'} + dependencies: + parse-ms: 4.0.0 + dev: false + /prosemirror-changeset@2.2.1: resolution: {integrity: sha512-J7msc6wbxB4ekDFj+n9gTW/jav/p53kdlivvuppHsrZXCaQdVgRghoZbSS3kwrRyAstRVQ4/+u5k7YfLgkkQvQ==} dependencies: @@ -9185,8 +9214,8 @@ packages: vue: 3.3.4 dev: false - /vue-demi@0.14.5(vue@3.3.4): - resolution: {integrity: sha512-o9NUVpl/YlsGJ7t+xuqJKx8EBGf1quRhCiT6D/J0pfwmk9zUwYkC7yrF4SZCe6fETvSM3UNL2edcbYrSyc4QHA==} + /vue-demi@0.14.10(vue@3.3.4): + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} engines: {node: '>=12'} hasBin: true requiresBuild: true @@ -9200,8 +9229,8 @@ packages: vue: 3.3.4 dev: false - /vue-demi@0.14.6(vue@3.3.4): - resolution: {integrity: sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==} + /vue-demi@0.14.5(vue@3.3.4): + resolution: {integrity: sha512-o9NUVpl/YlsGJ7t+xuqJKx8EBGf1quRhCiT6D/J0pfwmk9zUwYkC7yrF4SZCe6fETvSM3UNL2edcbYrSyc4QHA==} engines: {node: '>=12'} hasBin: true requiresBuild: true @@ -9497,6 +9526,7 @@ packages: /workbox-google-analytics@7.0.0: resolution: {integrity: sha512-MEYM1JTn/qiC3DbpvP2BVhyIH+dV/5BjHk756u9VbwuAhu0QHyKscTnisQuz21lfRpOwiS9z4XdqeVAKol0bzg==} + deprecated: It is not compatible with newer versions of GA starting with v4, as long as you are using GAv3 it should be ok, but the package is not longer being maintained dependencies: workbox-background-sync: 7.0.0 workbox-core: 7.0.0 diff --git a/src/tools/duration-calculator/duration-calculator.service.test.ts b/src/tools/duration-calculator/duration-calculator.service.test.ts new file mode 100644 index 00000000..9035587a --- /dev/null +++ b/src/tools/duration-calculator/duration-calculator.service.test.ts @@ -0,0 +1,313 @@ +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('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: 'P0Y0M0DT2H40M15S', + milliseconds: 9615125, + minutes: 160.25208333333333, + prettified: '2h 40m 15.1s', + prettifiedColonNotation: '2:40:15.1', + prettifiedDaysColon: '02:40:15.125', + prettifiedHoursColon: '02:40:15.125', + prettifiedVerbose: '2 hours 40 minutes 15.1 seconds', + 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: 0.5138891238425926, + hours: 12.333338972222222, + iso8601Duration: 'P0Y0M0DT12H20M0S', + milliseconds: 44400020.3, + minutes: 740.0003383333333, + prettified: '12h 20m', + prettifiedColonNotation: '12:20:00', + prettifiedDaysColon: '12:20:00.20.299999997019768', + prettifiedHoursColon: '12:20:00.20.299999997019768', + prettifiedVerbose: '12 hours 20 minutes', + seconds: 44400.0203, + weeks: 0.07341273197751322, + years: 0.0014079154077879248, + }, + }); + 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, + }, + }); + }); + }); +}); diff --git a/src/tools/duration-calculator/duration-calculator.service.ts b/src/tools/duration-calculator/duration-calculator.service.ts new file mode 100644 index 00000000..395e1761 --- /dev/null +++ b/src/tools/duration-calculator/duration-calculator.service.ts @@ -0,0 +1,127 @@ +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 | undefined + 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*/, ''); + const durationMS = convertDurationMS(cleanedDuration); + return { + rawLine: l, + cleanedDuration, + sign: isNeg ? -1 : 1, + durationMS, + isValid: !(typeof durationMS === 'undefined'), + }; + }); + + 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 | undefined { + const hoursHandled = s.replace(/\b(\d+):(\d+)(?::(\d+)(?:\.(\d+))?)?/g, (_, 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(h, 'h'); + addPart(m, 'm'); + addPart(s, 's'); + addPart(ms, 'ms'); + return timeArr.join(' '); + }); + if (!hoursHandled) { + return 0; + } + + let parsedDuration = parse(hoursHandled); + if (parsedDuration !== 0 && !parsedDuration) { + try { + parsedDuration = iso8601Duration.toMilliseconds(iso8601Duration.parse(hoursHandled)); + } + catch (_) { + return undefined; + } + } + return parsedDuration; +} +function prepareDurationResult(durationMS: any): ConvertedDuration { + const dateFnsDuration = intervalToDuration({ start: 0, end: durationMS }); + return { + prettified: prettyMilliseconds(durationMS), + prettifiedVerbose: prettyMilliseconds(durationMS, { verbose: true }), + prettifiedColonNotation: prettyMilliseconds(durationMS, { colonNotation: 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; + } + return `${d > 0 ? `${d}d ` : ''}${padNumber(h)}:${padNumber(m)}:${padNumber(s)}${ms > 0 ? `.${ms}` : ''}`; +} diff --git a/src/tools/duration-calculator/duration-calculator.vue b/src/tools/duration-calculator/duration-calculator.vue new file mode 100644 index 00000000..2f963206 --- /dev/null +++ b/src/tools/duration-calculator/duration-calculator.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/tools/duration-calculator/index.ts b/src/tools/duration-calculator/index.ts new file mode 100644 index 00000000..0d2c7fed --- /dev/null +++ b/src/tools/duration-calculator/index.ts @@ -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'), +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index 52bdf8e3..2db28463 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,6 +1,7 @@ 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 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'; @@ -151,7 +152,12 @@ export const toolsByCategory: ToolCategory[] = [ }, { name: 'Measurement', - components: [chronometer, temperatureConverter, benchmarkBuilder], + components: [ + chronometer, + temperatureConverter, + durationCalculator, + benchmarkBuilder, + ], }, { name: 'Text', From 4c381f3b6dd8ab8dd05983f5004e7482270979e0 Mon Sep 17 00:00:00 2001 From: ShareVB Date: Sun, 29 Sep 2024 20:58:51 +0200 Subject: [PATCH 3/8] fix: sonar --- .../duration-calculator/duration-calculator.service.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/tools/duration-calculator/duration-calculator.service.ts b/src/tools/duration-calculator/duration-calculator.service.ts index 395e1761..98dc4c85 100644 --- a/src/tools/duration-calculator/duration-calculator.service.ts +++ b/src/tools/duration-calculator/duration-calculator.service.ts @@ -32,15 +32,15 @@ export function computeDuration(s: string): { 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*/, ''); + const isNeg = /^\s*-/.test(l); + const cleanedDuration = l.replace(/^\s*[+-]\s*/, ''); const durationMS = convertDurationMS(cleanedDuration); return { rawLine: l, cleanedDuration, sign: isNeg ? -1 : 1, durationMS, - isValid: !(typeof durationMS === 'undefined'), + isValid: typeof durationMS !== 'undefined', }; }); @@ -123,5 +123,7 @@ function hhmmss(milliseconds: number, days: boolean) { d = Math.floor(h / 24); h = h % 24; } - return `${d > 0 ? `${d}d ` : ''}${padNumber(h)}:${padNumber(m)}:${padNumber(s)}${ms > 0 ? `.${ms}` : ''}`; + const formatted_d = d > 0 ? `${d}d ` : ''; + const formatted_ms = ms > 0 ? `.${ms}` : ''; + return `${formatted_d}${padNumber(h)}:${padNumber(m)}:${padNumber(s)}${formatted_ms}`; } From dec2e316546f30bc539520dd4a869973d0ad3a3c Mon Sep 17 00:00:00 2001 From: ShareVB Date: Sun, 27 Oct 2024 17:22:53 +0100 Subject: [PATCH 4/8] 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 --- components.d.ts | 11 +- package.json | 4 + pnpm-lock.yaml | 100 ++- .../date-duration-calculator.service.test.ts | 17 + .../date-duration-calculator.service.ts | 12 + .../date-duration-calculator.vue | 41 ++ src/tools/date-duration-calculator/index.ts | 12 + .../business-time-calculator.test.ts | 611 ++++++++++++++++++ .../business-time-calculator.ts | 341 ++++++++++ .../days-calculator.service.test.ts | 238 +++++++ .../days-calculator.service.ts | 161 +++++ src/tools/days-calculator/days-calculator.vue | 176 +++++ src/tools/days-calculator/index.ts | 12 + src/tools/index.ts | 6 +- 14 files changed, 1736 insertions(+), 6 deletions(-) create mode 100644 src/tools/date-duration-calculator/date-duration-calculator.service.test.ts create mode 100644 src/tools/date-duration-calculator/date-duration-calculator.service.ts create mode 100644 src/tools/date-duration-calculator/date-duration-calculator.vue create mode 100644 src/tools/date-duration-calculator/index.ts create mode 100644 src/tools/days-calculator/business-time-calculator.test.ts create mode 100644 src/tools/days-calculator/business-time-calculator.ts create mode 100644 src/tools/days-calculator/days-calculator.service.test.ts create mode 100644 src/tools/days-calculator/days-calculator.service.ts create mode 100644 src/tools/days-calculator/days-calculator.vue create mode 100644 src/tools/days-calculator/index.ts diff --git a/components.d.ts b/components.d.ts index ea73a54a..6ed10973 100644 --- a/components.d.ts +++ b/components.d.ts @@ -64,7 +64,9 @@ declare module '@vue/runtime-core' { 'CTextCopyable.demo': typeof import('./src/ui/c-text-copyable/c-text-copyable.demo.vue')['default'] CTooltip: typeof import('./src/ui/c-tooltip/c-tooltip.vue')['default'] 'CTooltip.demo': typeof import('./src/ui/c-tooltip/c-tooltip.demo.vue')['default'] + DateDurationCalculator: typeof import('./src/tools/date-duration-calculator/date-duration-calculator.vue')['default'] DateTimeConverter: typeof import('./src/tools/date-time-converter/date-time-converter.vue')['default'] + DaysCalculator: typeof import('./src/tools/days-calculator/days-calculator.vue')['default'] 'DemoHome.page': typeof import('./src/ui/demo/demo-home.page.vue')['default'] DemoWrapper: typeof import('./src/ui/demo/demo-wrapper.vue')['default'] DeviceInformation: typeof import('./src/tools/device-information/device-information.vue')['default'] @@ -131,22 +133,25 @@ declare module '@vue/runtime-core' { MetaTagGenerator: typeof import('./src/tools/meta-tag-generator/meta-tag-generator.vue')['default'] MimeTypes: typeof import('./src/tools/mime-types/mime-types.vue')['default'] NavbarButtons: typeof import('./src/components/NavbarButtons.vue')['default'] + NCheckbox: typeof import('naive-ui')['NCheckbox'] + NCheckboxGroup: typeof import('naive-ui')['NCheckboxGroup'] NCode: typeof import('naive-ui')['NCode'] NCollapseTransition: typeof import('naive-ui')['NCollapseTransition'] NConfigProvider: typeof import('naive-ui')['NConfigProvider'] + NDatePicker: typeof import('naive-ui')['NDatePicker'] NDivider: typeof import('naive-ui')['NDivider'] NEllipsis: typeof import('naive-ui')['NEllipsis'] - NGi: typeof import('naive-ui')['NGi'] - NGrid: typeof import('naive-ui')['NGrid'] + NFormItem: typeof import('naive-ui')['NFormItem'] NH1: typeof import('naive-ui')['NH1'] NH3: typeof import('naive-ui')['NH3'] NIcon: typeof import('naive-ui')['NIcon'] + NInputNumber: typeof import('naive-ui')['NInputNumber'] NLayout: typeof import('naive-ui')['NLayout'] NLayoutSider: typeof import('naive-ui')['NLayoutSider'] NMenu: typeof import('naive-ui')['NMenu'] NP: typeof import('naive-ui')['NP'] NScrollbar: typeof import('naive-ui')['NScrollbar'] - NTag: typeof import('naive-ui')['NTag'] + NSpace: typeof import('naive-ui')['NSpace'] NumeronymGenerator: typeof import('./src/tools/numeronym-generator/numeronym-generator.vue')['default'] OtpCodeGeneratorAndValidator: typeof import('./src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue')['default'] PasswordStrengthAnalyser: typeof import('./src/tools/password-strength-analyser/password-strength-analyser.vue')['default'] diff --git a/package.json b/package.json index e7e58d7a..0ed265dc 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@tiptap/starter-kit": "2.1.6", "@tiptap/vue-3": "2.0.3", "@types/figlet": "^1.5.8", + "@types/luxon": "^3.4.2", "@types/markdown-it": "^13.0.7", "@vicons/material": "^0.12.0", "@vicons/tabler": "^0.12.0", @@ -54,11 +55,13 @@ "change-case": "^4.1.2", "colord": "^2.9.3", "composerize-ts": "^0.6.2", + "countries-and-timezones": "^3.7.2", "country-code-lookup": "^0.1.0", "cron-validator": "^1.3.1", "cronstrue": "^2.26.0", "crypto-js": "^4.1.1", "date-fns": "^2.29.3", + "date-holidays": "^3.23.12", "dompurify": "^3.0.6", "duration-fns": "^3.0.2", "email-normalizer": "^1.0.0", @@ -74,6 +77,7 @@ "jwt-decode": "^3.1.2", "libphonenumber-js": "^1.10.28", "lodash": "^4.17.21", + "luxon": "^3.5.0", "markdown-it": "^14.0.0", "marked": "^10.0.0", "mathjs": "^11.9.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e078fa5a..57e9be3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ dependencies: '@types/figlet': specifier: ^1.5.8 version: 1.5.8 + '@types/luxon': + specifier: ^3.4.2 + version: 3.4.2 '@types/markdown-it': specifier: ^13.0.7 version: 13.0.9 @@ -59,6 +62,9 @@ dependencies: composerize-ts: specifier: ^0.6.2 version: 0.6.2 + countries-and-timezones: + specifier: ^3.7.2 + version: 3.7.2 country-code-lookup: specifier: ^0.1.0 version: 0.1.0 @@ -74,6 +80,9 @@ dependencies: date-fns: specifier: ^2.29.3 version: 2.29.3 + date-holidays: + specifier: ^3.23.12 + version: 3.23.12 dompurify: specifier: ^3.0.6 version: 3.0.6 @@ -119,6 +128,9 @@ dependencies: lodash: specifier: ^4.17.21 version: 4.17.21 + luxon: + specifier: ^3.5.0 + version: 3.5.0 markdown-it: specifier: ^14.0.0 version: 14.1.0 @@ -3005,6 +3017,10 @@ packages: resolution: {integrity: sha512-YI/M/4HRImtNf3pJgbF+W6FrXovqj+T+/HpENLTooK9PnkacBsDpeP3IpHab40CClUfhNmdM2WTNP2sa2dni5Q==} dev: false + /@types/luxon@3.4.2: + resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} + dev: false + /@types/markdown-it@12.2.3: resolution: {integrity: sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==} dependencies: @@ -4192,6 +4208,11 @@ packages: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} dev: true + /astronomia@4.1.1: + resolution: {integrity: sha512-TcJD9lUC5eAo0/Ji7rnQauX/yQbi0yZWM+JsNr77W3OA5fsrgvuFgubLMFwfw4VlZ29cu9dG/yfJbfvuTSftjg==} + engines: {node: '>=12.0.0'} + dev: false + /async-validator@4.2.5: resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} dev: false @@ -4350,6 +4371,13 @@ packages: engines: {node: '>=8'} dev: true + /caldate@2.0.5: + resolution: {integrity: sha512-JndhrUuDuE975KUhFqJaVR1OQkCHZqpOrJur/CFXEIEhWhBMjxO85cRSK8q4FW+B+yyPq6GYua2u4KvNzTcq0w==} + engines: {node: '>=12.0.0'} + dependencies: + moment-timezone: 0.5.46 + dev: false + /call-bind@1.0.5: resolution: {integrity: sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==} dependencies: @@ -4666,6 +4694,11 @@ packages: browserslist: 4.22.1 dev: true + /countries-and-timezones@3.7.2: + resolution: {integrity: sha512-BHAMt4pKb3U3r/mRfiIlVnDhRd8m6VC20gwCWtpZGZkSsjZmnMDKFnnjWYGWhBmypQAqcQILFJwmEhIgWGVTmw==} + engines: {node: '>=8.x', npm: '>=5.x'} + dev: false + /country-code-lookup@0.1.0: resolution: {integrity: sha512-IOI66HEG+8bXfWPy+sTzuN7161vmDZOHg1wgIPFf3WfD73FeLajnn6C+fnxOIa9RL1WRBDMXQQWW/FOaOYaQ3w==} dev: false @@ -4783,6 +4816,23 @@ packages: whatwg-url: 12.0.1 dev: true + /date-bengali-revised@2.0.2: + resolution: {integrity: sha512-q9iDru4+TSA9k4zfm0CFHJj6nBsxP7AYgWC/qodK/i7oOIlj5K2z5IcQDtESfs/Qwqt/xJYaP86tkazd/vRptg==} + engines: {node: '>=12.0.0'} + dev: false + + /date-chinese@2.1.4: + resolution: {integrity: sha512-WY+6+Qw92ZGWFvGtStmNQHEYpNa87b8IAQ5T8VKt4wqrn24lBXyyBnWI5jAIyy7h/KVwJZ06bD8l/b7yss82Ww==} + engines: {node: '>=12.0.0'} + dependencies: + astronomia: 4.1.1 + dev: false + + /date-easter@1.0.3: + resolution: {integrity: sha512-aOViyIgpM4W0OWUiLqivznwTtuMlD/rdUWhc5IatYnplhPiWrLv75cnifaKYhmQwUBLAMWLNG4/9mlLIbXoGBQ==} + engines: {node: '>=12.0.0'} + dev: false + /date-fns-tz@2.0.0(date-fns@2.30.0): resolution: {integrity: sha512-OAtcLdB9vxSXTWHdT8b398ARImVwQMyjfYGkKD2zaGpHseG2UPHbHjXELReErZFxWdSLph3c2zOaaTyHfOhERQ==} peerDependencies: @@ -4803,6 +4853,31 @@ packages: '@babel/runtime': 7.23.2 dev: false + /date-holidays-parser@3.4.4: + resolution: {integrity: sha512-R5aO4oT8H51ZKdvApqHrqYEiNBrqT6tRj2PFXNcZfqMI4nxY7KKKly0ZsmquR5gY+x9ldKR8SAMdozzIInaoXg==} + engines: {node: '>=12.0.0'} + dependencies: + astronomia: 4.1.1 + caldate: 2.0.5 + date-bengali-revised: 2.0.2 + date-chinese: 2.1.4 + date-easter: 1.0.3 + deepmerge: 4.3.1 + jalaali-js: 1.2.7 + moment-timezone: 0.5.46 + dev: false + + /date-holidays@3.23.12: + resolution: {integrity: sha512-DLyP0PPVgNydgaTAY7SBS26+5h3KO1Z8FRKiAROkz0hAGNBLGAM48SMabfVa2ACRHH7Qw3LXYvlJkt9oa9WePA==} + engines: {node: '>=12.0.0'} + hasBin: true + dependencies: + date-holidays-parser: 3.4.4 + js-yaml: 4.1.0 + lodash: 4.17.21 + prepin: 1.0.3 + dev: false + /de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} dev: false @@ -6521,6 +6596,10 @@ packages: minimatch: 3.1.2 dev: true + /jalaali-js@1.2.7: + resolution: {integrity: sha512-gE+YHWSbygYAoJa+Xg8LWxGILqFOxZSBQQw39ghel01fVFUxV7bjL0x1JFsHcLQ3uPjvn81HQMa+kxwyPWnxGQ==} + dev: false + /javascript-natural-sort@0.7.1: resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} dev: false @@ -6573,7 +6652,6 @@ packages: hasBin: true dependencies: argparse: 2.0.1 - dev: true /jsbn@1.1.0: resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} @@ -6830,6 +6908,11 @@ packages: dependencies: yallist: 4.0.0 + /luxon@3.5.0: + resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} + engines: {node: '>=12'} + dev: false + /magic-string@0.25.9: resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} dependencies: @@ -7036,6 +7119,16 @@ packages: pkg-types: 1.0.3 ufo: 1.3.1 + /moment-timezone@0.5.46: + resolution: {integrity: sha512-ZXm9b36esbe7OmdABqIWJuBBiLLwAjrN7CE+7sYdCCx82Nabt1wHDj8TVseS59QIlfFPbOoiBPm6ca9BioG4hw==} + dependencies: + moment: 2.30.1 + dev: false + + /moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + dev: false + /monaco-editor@0.43.0: resolution: {integrity: sha512-cnoqwQi/9fml2Szamv1XbSJieGJ1Dc8tENVMD26Kcfl7xGQWp7OBKMjlwKVGYFJ3/AXJjSOGvcqK7Ry/j9BM1Q==} dev: false @@ -7570,6 +7663,11 @@ packages: engines: {node: '>= 0.8.0'} dev: true + /prepin@1.0.3: + resolution: {integrity: sha512-0XL2hreherEEvUy0fiaGEfN/ioXFV+JpImqIzQjxk6iBg4jQ2ARKqvC4+BmRD8w/pnpD+lbxvh0Ub+z7yBEjvA==} + hasBin: true + dev: false + /prettier@3.0.0: resolution: {integrity: sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g==} engines: {node: '>=14'} diff --git a/src/tools/date-duration-calculator/date-duration-calculator.service.test.ts b/src/tools/date-duration-calculator/date-duration-calculator.service.test.ts new file mode 100644 index 00000000..10953117 --- /dev/null +++ b/src/tools/date-duration-calculator/date-duration-calculator.service.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { addToDate } from './date-duration-calculator.service'; + +describe('date-duration-calculator', () => { + describe('addToDate', () => { + it('compute right values', () => { + expect(addToDate(new Date('2024-08-15T07:21:46Z'), '+1d 1m 20s')).to.deep.eq( + { + date: new Date('2024-08-16T07:23:06.000Z'), + durationPretty: '1d 1m 20s', + durationSeconds: 86480, + errors: [], + }, + ); + }); + }); +}); diff --git a/src/tools/date-duration-calculator/date-duration-calculator.service.ts b/src/tools/date-duration-calculator/date-duration-calculator.service.ts new file mode 100644 index 00000000..03898642 --- /dev/null +++ b/src/tools/date-duration-calculator/date-duration-calculator.service.ts @@ -0,0 +1,12 @@ +import { computeDuration } from '../duration-calculator/duration-calculator.service'; + +export function addToDate(date: Date, durations: string) { + const { total, errors } = computeDuration(durations); + + return { + errors, + date: new Date(date.getTime() + total.milliseconds), + durationSeconds: total.seconds, + durationPretty: total.prettified, + }; +} diff --git a/src/tools/date-duration-calculator/date-duration-calculator.vue b/src/tools/date-duration-calculator/date-duration-calculator.vue new file mode 100644 index 00000000..28091bff --- /dev/null +++ b/src/tools/date-duration-calculator/date-duration-calculator.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/tools/date-duration-calculator/index.ts b/src/tools/date-duration-calculator/index.ts new file mode 100644 index 00000000..328ca0fd --- /dev/null +++ b/src/tools/date-duration-calculator/index.ts @@ -0,0 +1,12 @@ +import { Calendar } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'Date+Durations Calculator', + path: '/date-duration-calculator', + description: 'Add/substract durations from a specific date', + keywords: ['date', 'duration', 'addition', 'calculator'], + component: () => import('./date-duration-calculator.vue'), + icon: Calendar, + createdAt: new Date('2024-08-15'), +}); diff --git a/src/tools/days-calculator/business-time-calculator.test.ts b/src/tools/days-calculator/business-time-calculator.test.ts new file mode 100644 index 00000000..7977a864 --- /dev/null +++ b/src/tools/days-calculator/business-time-calculator.test.ts @@ -0,0 +1,611 @@ +import { DateTime } from 'luxon'; +import { describe, expect, it } from 'vitest'; +import type { DayOfWeek, Holiday } from './business-time-calculator'; +import { BusinessTime } from './business-time-calculator'; + +const weekDays: DayOfWeek[] = [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', +]; + +const allDays: DayOfWeek[] = [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', +]; + +interface TestCase { + businessTimezone: string + businessDays: DayOfWeek[] + businessHours: number[] + holidays: Holiday[] + start?: string + end?: string + expected: any +} + +type BusinessTimeMethod = keyof InstanceType; + +function testEachComputeTime(testCases: TestCase[], + businessTimeFunctionName: BusinessTimeMethod) { + for (const { + start, + end, + businessTimezone, + businessHours, + businessDays, + holidays, + expected, + } of testCases) { + if (!start || !end) { + throw new Error('Start and end dates must be defined'); + } + + const startDatetime = DateTime.fromISO(start) as DateTime; + if (!startDatetime.isValid) { + throw new Error(`Invalid start datetime: ${start}`); + } + const endDatetime = DateTime.fromISO(end) as DateTime; + if (!endDatetime.isValid) { + throw new Error(`Invalid end datetime: ${end}`); + } + const businessTime = new BusinessTime({ + businessTimezone, + businessHours, + businessDays, + holidays, + }); + + expect( + businessTime[businessTimeFunctionName]({ + start: startDatetime, + end: endDatetime, + } as never)).to.deep.eq(expected); + } +} + +function testEachMoveDateInBusinessTime(testCases: (TestCase & { datetime: string; moveBehind: boolean })[]) { + for (const { + businessTimezone, + businessHours, + businessDays, + holidays, + datetime, + moveBehind, + expected, + } of testCases) { + const businessTime = new BusinessTime({ + businessTimezone, + businessHours, + businessDays, + holidays, + }); + + expect( + businessTime + ._moveDateInBusinessTime({ + datetime: DateTime.fromISO(datetime), + moveBehind, + }) + .toISO()).to.deep.eq( + expected, + ); + } +} + +function testEachIsBusinessDay(testCases: (TestCase & { datetime: string })[]) { + for (const { + datetime, + businessTimezone, + businessHours, + businessDays, + holidays, + expected, + } of testCases) { + const datetimeObj = DateTime.fromISO(datetime) as DateTime; + if (!datetimeObj.isValid) { + throw new Error(`Invalid datetime: ${datetime}`); + } + const businessTime = new BusinessTime({ + businessTimezone, + businessHours, + businessDays, + holidays, + }); + + expect(businessTime.isBusinessDay(datetimeObj)).to.deep.eq(expected); + } +} + +function testEachAddBusinessSecondsToDate(testCases: (TestCase & { datetime: string; seconds: number })[]) { + for (const { + seconds, + businessTimezone, + businessHours, + businessDays, + holidays, + datetime, + expected, + } of testCases) { + const datetimeObj = DateTime.fromISO(datetime) as DateTime; + if (!datetimeObj.isValid) { + throw new Error(`Invalid datetime: ${datetime}`); + } + const businessTime = new BusinessTime({ + businessTimezone, + businessHours, + businessDays, + holidays, + }); + + expect( + businessTime + .addBusinessSecondsToDate({ datetime: datetimeObj, seconds }) + .toISO()).to.deep.eq( + expected, + ); + } +} + +function testEachRemoveBusinessSecondsToDate(testCases: (TestCase & { datetime: string; seconds: number })[]) { + for (const { + seconds, + businessTimezone, + businessHours, + businessDays, + holidays, + datetime, + expected, + } of testCases) { + const datetimeObj = DateTime.fromISO(datetime) as DateTime; + if (!datetimeObj.isValid) { + throw new Error(`Invalid datetime: ${datetime}`); + } + const businessTime = new BusinessTime({ + businessTimezone, + businessHours, + businessDays, + holidays, + }); + + expect( + businessTime + .removeBusinessSecondsFromDate({ datetime: datetimeObj, seconds }) + .toISO()).to.deep.eq( + expected, + ); + } +} + +describe('BusinessTime', () => { + it('compute business days', () => { + testEachComputeTime( + [ + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + start: '2020-12-28T09:00:00.000+01:00', + end: '2020-12-29T23:00:00.000+01:00', + expected: 2, + }, + ], + 'computeBusinessDaysInInterval', + ); + }); + + it('compute business hours', () => { + testEachComputeTime( + [ + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + start: '2020-12-28T13:45:00.000+01:00', + end: '2020-12-28T14:00:00.000+01:00', + expected: 0.25, + }, // same hour + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: ['25/12', '26/12'], + start: '2020-12-25T10:45:00.000+01:00', + end: '2020-12-27T10:00:00.000+01:00', + expected: 0, + }, // holidays days + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: ['25/12/2020', '26/12/2020'], + start: '2020-12-25T10:45:00.000+01:00', + end: '2020-12-27T10:00:00.000+01:00', + expected: 0, + }, // holidays days and dates + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: ['25/12/2022', '26/12/2022'], + start: '2020-12-25T10:45:00.000+01:00', + end: '2020-12-27T10:00:00.000+01:00', + expected: 8.25, + }, // holidays days and dates (wrong year) + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + start: '2020-12-28T14:00:00.000+01:00', + end: '2020-12-28T18:30:00.000+01:00', + expected: 4.5, + }, // same day + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + start: '2020-12-18T14:00:00.000+01:00', + end: '2020-12-21T14:30:00.000+01:00', + expected: 9.5, + }, // cross weekend + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + start: '2020-12-28T15:00:00.000+01:00', + end: '2020-12-28T20:00:00.000+01:00', + expected: 4, + }, // 4 hours in Rome + { + businessTimezone: 'America/Los_Angeles', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + start: '2020-12-28T15:00:00.000+01:00', + end: '2020-12-28T20:00:00.000+01:00', + expected: 1, + }, // 1 hour in San Francisco + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + start: '2021-01-04T10:00:00.000+01:00', + end: '2021-03-01T10:00:00.000+01:00', + expected: 360, + }, // 8 weeks, 45 hours / week => 360 + ], + 'computeBusinessHoursInInterval', + ); + }); + + it('compute business minutes', () => { + testEachComputeTime( + [ + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + start: '2020-12-28T13:45:00.000+01:00', + end: '2020-12-28T14:00:00.000+01:00', + expected: 15, + }, + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: ['01/01'], + start: '2020-12-31T13:45:00.000+01:00', + end: '2021-01-04T19:00:00.000+01:00', + expected: 855, + }, + ], + 'computeBusinessMinutesInInterval', + ); + }); + + it('compute business seconds', () => { + testEachComputeTime( + [ + { + businessTimezone: 'America/Los_Angeles', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + start: '2020-12-28T15:00:00.000+01:00', + end: '2020-12-28T20:00:00.000+01:00', + expected: 3600, + }, // 1 hour in San Francisco + ], + 'computeBusinessSecondsInInterval', + ); + }); + + it('compute isBusinessDay', () => { + testEachIsBusinessDay([ + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday'], + businessHours: [10, 19], + holidays: ['25/12', '26/12'], + datetime: '2020-12-28T14:00:00.000+01:00', + expected: true, + }, // monday + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday', 'friday'], + businessHours: [10, 19], + holidays: ['26/12'], + datetime: '2020-12-25T14:00:00.000+01:00', + expected: true, + }, // Christmas 2020 (friday) configured as business day + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday'], + holidays: ['25/12', '26/12'], + businessHours: [10, 19], + datetime: '2020-12-27T14:00:00.000+01:00', + expected: false, + }, // tuesday configured as rest day + { + businessTimezone: 'America/Los_Angeles', + businessDays: ['monday'], + businessHours: [10, 19], + holidays: ['25/12', '26/12'], + datetime: '2020-12-28T01:00:00.000+01:00', + expected: false, + }, // monday in Rome, sunday in San Francisco + ]); + }); + + it('compute moveDateInBusinessTime', () => { + testEachMoveDateInBusinessTime([ + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday'], + businessHours: [13, 15], + holidays: [], + moveBehind: false, + datetime: '2020-12-28T11:00:00.000+01:00', + expected: '2020-12-28T13:00:00.000+01:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday'], + businessHours: [13, 15], + holidays: [], + moveBehind: false, + datetime: '2020-12-28T14:00:00.000+01:00', + expected: '2020-12-28T14:00:00.000+01:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday'], + businessHours: [13, 15], + holidays: ['01/01'], + moveBehind: false, + datetime: '2020-12-28T16:00:00.000+01:00', + expected: '2021-01-04T13:00:00.000+01:00', + }, + { + businessTimezone: 'America/Los_Angeles', + businessDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], + businessHours: [10, 19], + holidays: [], + moveBehind: false, + datetime: '2021-06-15T00:00:00.000+02:00', // tuesday + expected: '2021-06-14T15:00:00.000-07:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday', 'tuesday'], + businessHours: [13, 15], + holidays: [], + moveBehind: true, + datetime: '2022-04-12T11:00:00.000+02:00', + expected: '2022-04-11T15:00:00.000+02:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday'], + businessHours: [13, 15], + holidays: [], + moveBehind: true, + datetime: '2020-12-28T14:00:00.000+01:00', + expected: '2020-12-28T14:00:00.000+01:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday'], + businessHours: [13, 15], + holidays: ['01/01'], + moveBehind: true, + datetime: '2022-04-11T11:00:00.000+02:00', + expected: '2022-04-04T15:00:00.000+02:00', + }, + { + businessTimezone: 'America/Los_Angeles', + businessDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], + businessHours: [10, 19], + holidays: [], + moveBehind: true, + datetime: '2021-06-15T00:00:00.000+02:00', // tuesday + expected: '2021-06-14T15:00:00.000-07:00', + }, + ]); + }); + + it('add business seconds to date', () => { + testEachAddBusinessSecondsToDate([ + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + datetime: '2020-12-28T10:45:00.000+01:00', + seconds: 3600 * 10, + expected: '2020-12-29T10:45:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: weekDays, + businessHours: [10, 19], + holidays: [], + datetime: '2022-04-04T19:45:00.000+02:00', + seconds: 3600 * 10, + expected: '2022-04-06T09:00:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: allDays, + businessHours: [0, 24], + holidays: ['01/01'], + datetime: '2020-12-28T10:45:00.000+01:00', + seconds: 3600 * 96, + expected: '2021-01-02T09:45:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: allDays, + businessHours: [0, 24], + holidays: [], + datetime: '2020-12-28T10:45:00.000+01:00', + seconds: 3600 * 96, + expected: '2021-01-01T09:45:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: allDays, + businessHours: [0, 12], + holidays: [], + datetime: '2020-12-28T10:45:00.000+01:00', + seconds: 3600 * 24, + expected: '2020-12-30T09:45:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: allDays, + businessHours: [0, 12], + holidays: [], + datetime: '2022-04-11T18:00:00.000+02:00', + seconds: 3600 * 24, + expected: '2022-04-13T10:00:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: allDays, + businessHours: [12, 24], + holidays: [], + datetime: '2022-04-04T10:00:00.000+02:00', + seconds: 3600 * 24, + expected: '2022-04-05T22:00:00.000+00:00', + }, + ]); + }); + + it('remove business seconds from date', () => { + testEachRemoveBusinessSecondsToDate([ + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], + businessHours: [10, 19], + holidays: [], + datetime: '2020-12-28T10:45:00.000+01:00', + seconds: 3600 * 10, + expected: '2020-12-24T17:45:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], + businessHours: [10, 19], + holidays: [], + datetime: '2022-04-08T19:45:00.000+02:00', + seconds: 3600 * 10, + expected: '2022-04-07T16:00:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: allDays, + businessHours: [0, 24], + holidays: ['25/12'], + datetime: '2020-12-28T10:11:11.111+01:00', + seconds: 3600 * 96, // 4 days + expected: '2020-12-23T09:11:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], // handle weekend + businessHours: [0, 24], + holidays: [], + datetime: '2022-04-11T12:00:00.000+02:00', + seconds: 3600 * 48, // 2 days + expected: '2022-04-07T10:00:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], // handle weekend + businessHours: [1, 24], + holidays: [], + datetime: '2022-04-11T12:00:00.000+02:00', + seconds: 3600 * 48, // 2 days + expected: '2022-04-07T08:00:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: allDays, + businessHours: [0, 12], + holidays: [], + datetime: '2022-04-08T10:45:00.000+02:00', + seconds: 3600 * 24, + expected: '2022-04-06T08:45:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: allDays, + businessHours: [0, 12], + holidays: [], + datetime: '2022-04-08T18:00:00.000+02:00', + seconds: 3600 * 24, + expected: '2022-04-06T22:00:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: allDays, + businessHours: [12, 24], + holidays: [], + datetime: '2022-04-08T10:00:00.000+02:00', + seconds: 3600 * 24, + expected: '2022-04-06T10:00:00.000+00:00', + }, + { + businessTimezone: 'Europe/Rome', + businessDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], // handle weekend + businessHours: [12, 24], + holidays: [], + datetime: '2022-04-11T10:00:00.000+02:00', + seconds: 3600 * 24, + expected: '2022-04-07T10:00:00.000+00:00', + }, + ]); + }); + + it('compute working hours', () => { + expect(BusinessTime.computeWorkingHours(10, 19)).toBe(9); + expect(BusinessTime.computeWorkingHours(0, 24)).toBe(24); + expect(BusinessTime.computeWorkingHours(18, 3)).toBe(9); + expect(BusinessTime.computeWorkingHours(22, 0)).toBe(2); + }); +}); diff --git a/src/tools/days-calculator/business-time-calculator.ts b/src/tools/days-calculator/business-time-calculator.ts new file mode 100644 index 00000000..e6e8f291 --- /dev/null +++ b/src/tools/days-calculator/business-time-calculator.ts @@ -0,0 +1,341 @@ +import type { DateTime } from 'luxon'; + +export type DayOfWeek = + | 'monday' + | 'tuesday' + | 'wednesday' + | 'thursday' + | 'friday' + | 'saturday' + | 'sunday'; + +const weekDayToName = { + 1: 'monday', + 2: 'tuesday', + 3: 'wednesday', + 4: 'thursday', + 5: 'friday', + 6: 'saturday', + 7: 'sunday', +}; + +export type Holiday = `${3 | 2 | 1 | 0}${number}/${1 | 0}${number}` | `${3 | 2 | 1 | 0}${number}/${1 | 0}${number}/${number}${number}${number}${number}`; + +export class BusinessTime { + private readonly businessTimezone: string; + + private readonly businessDays: DayOfWeek[]; + private readonly holidays: Holiday[]; + private readonly startOfDayTime: { hour: number; minute: number; second: number }; + private readonly endOfDayTime: { hour: number; minute: number; second: number }; + + static readonly computeWorkingHours = (startHour: number, endHour: number) => { + if (endHour < startHour) { + const workingHours = Math.abs(Math.abs(startHour - 24) + endHour); + return workingHours; + } + + const workingHours = endHour - startHour; + return workingHours; + }; + + constructor({ + businessTimezone, + businessDays, + businessHours, + holidays, + }: { + businessTimezone: string + businessDays: DayOfWeek[] + businessHours: number[] + holidays: Holiday[] + }) { + this.businessTimezone = businessTimezone; + this.businessDays = businessDays; + this.holidays = holidays; + this.startOfDayTime = { + hour: businessHours[0], + minute: 0, + second: 0, + }; + this.endOfDayTime = { + hour: businessHours[1], + minute: 0, + second: 0, + }; + } + + computeWorkingHours = () => { + const workingHours = BusinessTime.computeWorkingHours( + this.startOfDayTime.hour, + this.endOfDayTime.hour, + ); + return workingHours; + }; + + isBusinessDay(datetime: DateTime) { + const date = datetime.setZone(this.businessTimezone); + if (!date.isValid) { + throw new Error('Invalid date'); + } + + const dayMonth = date.toFormat('dd/MM') as Holiday; + const dayMonthYear = date.toFormat('dd/MM/yyyy') as Holiday; + if (this.holidays.includes(dayMonth) || this.holidays.includes(dayMonthYear)) { + return false; + } + + if (this.businessDays.includes(weekDayToName[date.weekday] as DayOfWeek)) { + return true; + } + + return false; + } + + computeBusinessDaysInInterval({ + start, + end, + }: { + start: DateTime + end: DateTime + }) { + const businessHours = this.computeBusinessHoursInInterval({ start, end }); + const workingHours = this.computeWorkingHours(); + return businessHours / workingHours; + } + + computeBusinessHoursInInterval({ + start, + end, + }: { + start: DateTime + end: DateTime + }) { + return this.computeBusinessTimeInInterval({ start, end, unit: 'hours' }); + } + + computeBusinessMinutesInInterval({ + start, + end, + }: { + start: DateTime + end: DateTime + }) { + return this.computeBusinessTimeInInterval({ start, end, unit: 'minutes' }); + } + + computeBusinessSecondsInInterval({ + start, + end, + }: { + start: DateTime + end: DateTime + }) { + return this.computeBusinessTimeInInterval({ start, end, unit: 'seconds' }); + } + + computeBusinessTimeInInterval({ + start, + end, + unit, + }: { + start: DateTime + end: DateTime + unit: 'hours' | 'minutes' | 'seconds' + }) { + if (start > end) { + throw new Error('start date is greater than end date'); + } + + const interval = { + start: this._moveDateInBusinessTime({ datetime: start }), + end: this._moveDateInBusinessTime({ datetime: end }), + }; + + let datetime = interval.start; + let businessTime = 0; + + while (datetime < interval.end) { + if (!this.isBusinessDay(datetime)) { + datetime = datetime.plus({ days: 1 }).set(this.startOfDayTime); + continue; + } + + if (datetime.toISODate() === interval.end.toISODate()) { + businessTime += interval.end.diff(datetime).as(unit); + datetime = interval.end; + } + else { + const endOfBusinessDay = datetime.set(this.endOfDayTime); + businessTime += endOfBusinessDay.diff(datetime).as(unit); + datetime = datetime.plus({ days: 1 }).set(this.startOfDayTime); + } + } + + return businessTime; + } + + /** + * Move the date in a business time (moveBehind = false) + * e.g. 06:00 => 10:00 of the current day + * e.g. 22:00 => 10:00 of the next day + * + * Move the date in a business time (moveBehind = true) + * e.g. 06:00 => 19:00 of the previous day + * e.g. 22:00 => 19:00 of the current day + * + * Warning ⚠️ _moveDateInBusinessTime doesn't retain the original timezone of the datetime in input, but it returns a datetime with the same timezone used to compute business times. + * It follows that behaviour because this method should be private and used only as helper. It is public only for testing purpose. + */ + _moveDateInBusinessTime({ + datetime, + moveBehind = false, + }: { + datetime: DateTime + moveBehind?: boolean + }) { + let date = datetime.setZone(this.businessTimezone); + const start = date.set(this.startOfDayTime); + const end = date.set(this.endOfDayTime); + + if (date < start) { + // Move datetime to the start / end of the business day + date = moveBehind + ? date.minus({ days: 1 }).set(this.endOfDayTime) + : start; + } + if (date > end) { + // Move datetime to the start of the next / previous day + date = moveBehind + ? date.set(this.endOfDayTime) + : date.plus({ days: 1 }).set(this.startOfDayTime); + } + while (this.businessDays.length && !this.isBusinessDay(date)) { + // Move datetime to the start of the next / previous business day + date = moveBehind + ? date.minus({ days: 1 }).set(this.endOfDayTime) + : date.plus({ days: 1 }).set(this.startOfDayTime); + } + return date; + } + + addBusinessHoursToDate({ + datetime, + hours, + }: { + datetime: DateTime + hours: number + }) { + return this.addBusinessSecondsToDate({ datetime, seconds: 3600 * hours }); + } + + addBusinessSecondsToDate({ + datetime, + seconds, + }: { + datetime: DateTime + seconds: number + }) { + if (seconds === 0) { + return datetime; + } + + let date = this._moveDateInBusinessTime({ datetime }); + let remainingSeconds = seconds; + while (remainingSeconds > 0) { + if (!this.isBusinessDay(date)) { + date = date.plus({ days: 1 }); + continue; + } + + const endOfBusinessDay = date.set(this.endOfDayTime); + const secondsUntilEndOfBusinessDay = endOfBusinessDay + .diff(date) + .as('seconds'); + + if (remainingSeconds <= secondsUntilEndOfBusinessDay) { + // remaining seconds are less than 1 business day + date = date.plus({ seconds: remainingSeconds }); + remainingSeconds = 0; + } + else { + // Move to the start of the next day + date = date.plus({ days: 1 }).set(this.startOfDayTime); + remainingSeconds -= secondsUntilEndOfBusinessDay; + } + } + + return date.set({ second: 0, millisecond: 0 }).setZone(datetime.zone); + } + + removeBusinessHoursFromDate({ + datetime, + hours, + }: { + datetime: DateTime + hours: number + }) { + return this.removeBusinessSecondsFromDate({ + datetime, + seconds: 3600 * hours, + }); + } + + removeBusinessSecondsFromDate({ + datetime, + seconds, + }: { + datetime: DateTime + seconds: number + }) { + if (seconds === 0) { + return datetime; + } + + let date = this._moveDateInBusinessTime({ datetime, moveBehind: true }); + let remainingSeconds = seconds; + while (remainingSeconds > 0) { + if (!this.isBusinessDay(date)) { + date = date.minus({ days: 1 }); + continue; + } + + const startOfBusinessDay + = date.hour === 0 && date.minute === 0 + ? date.minus({ days: 1 }).set(this.startOfDayTime) + : date.set(this.startOfDayTime); + const secondsFromStartOfBusinessDay = date + .diff(startOfBusinessDay) + .as('seconds'); + + if (remainingSeconds <= secondsFromStartOfBusinessDay) { + // remaining seconds are less than 1 business day + date = date.minus({ seconds: remainingSeconds }); + remainingSeconds = 0; + } + else { + // Move to the end of the previous day + date = date.minus({ days: 1 }); + + // handle special case 24h business days. If it is midnight and endOfDayTime is midnight, we must not set the date to the end of the day, otherwise we lose the effect of removing 1 day + if ( + !( + date.hour === 0 + && date.minute === 0 + && this.endOfDayTime.hour === 24 + ) + ) { + date = date.set(this.endOfDayTime); + } + remainingSeconds -= secondsFromStartOfBusinessDay; + } + } + + return date.set({ second: 0, millisecond: 0 }).setZone(datetime.zone); + } + + hoursToDays(hours: number) { + const days = hours / this.computeWorkingHours(); + return days; + } +} diff --git a/src/tools/days-calculator/days-calculator.service.test.ts b/src/tools/days-calculator/days-calculator.service.test.ts new file mode 100644 index 00000000..c2c2482e --- /dev/null +++ b/src/tools/days-calculator/days-calculator.service.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from 'vitest'; +import { DateTime } from 'luxon'; +import { countCertainDays, datesByDays, diffDateTimes } from './days-calculator.service'; + +describe('days-calculator', () => { + describe('diffDateTimes', () => { + it('compute right values', () => { + const daysInfos = { + saturdays: [ + '2024-08-03', + '2024-08-10', + '2024-08-17', + '2024-08-24', + '2024-08-31', + ], + tuesdays: [ + '2024-08-06', + '2024-08-13', + '2024-08-20', + '2024-08-27', + ], + sundays: [ + '2024-08-04', + '2024-08-11', + '2024-08-18', + '2024-08-25', + ], + mondays: [ + '2024-08-05', + '2024-08-12', + '2024-08-19', + '2024-08-26', + ], + fridays: [ + '2024-08-02', + '2024-08-09', + '2024-08-16', + '2024-08-23', + '2024-08-30', + ], + + wednesdays: [ + '2024-08-07', + '2024-08-14', + '2024-08-21', + '2024-08-28', + ], + thursdays: [ + '2024-08-01', + '2024-08-08', + '2024-08-15', + '2024-08-22', + '2024-08-29', + ], + weekendDays: 9, + weekends: 4, + }; + const holidays = [ + { + date: '2024-08-15 00:00:00', + end: new Date('2024-08-15T22:00:00.000Z'), + name: 'Assomption', + rule: '08-15', + start: new Date('2024-08-14T22:00:00.000Z'), + type: 'public', + }, + ]; + + const date1 = new Date('2024-08-01T07:21:46Z'); + const date2 = new Date('2024-08-31T17:21:46Z'); + + expect(diffDateTimes({ + date1, + date2, + country: 'FR', + businessTimezone: 'Europe/Paris', + includeWeekDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], + includeEndDate: true, + includeHolidays: true, + businessStartHour: 9, + businessEndHour: 18, + })).to.deep.eq({ + startDate: date1, + endDate: date2, + businessDays: 29.959691358024696, + businessHours: 269.63722222222225, + businessSeconds: 970694, + businessSecondsFormatted: '11d 5h 38m 14s', + differenceFormatted: '29d 10h', + differenceSeconds: 2541600, + totalDifferenceFormatted: '30d 10h', + totalDifferenceSeconds: 2628000, + holidays, + ...daysInfos, + }); + expect(diffDateTimes({ + date1, + date2, + country: 'FR', + businessTimezone: 'Europe/Paris', + includeEndDate: false, + includeWeekDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'], + includeHolidays: true, + businessStartHour: 9, + businessEndHour: 18, + })).to.deep.eq({ + startDate: date1, + endDate: new Date('2024-08-30T23:59:59.999Z'), + businessDays: 28.959722191358026, + businessHours: 260.63749972222223, + businessSeconds: 938294.999, + businessSecondsFormatted: '10d 20h 38m 14.9s', + differenceFormatted: '28d 16h 38m 13.9s', + differenceSeconds: 2479093.999, + totalDifferenceFormatted: '29d 16h 38m 13.9s', + totalDifferenceSeconds: 2565493.999, + holidays, + ...daysInfos, + saturdays: [ + '2024-08-03', + '2024-08-10', + '2024-08-17', + '2024-08-24', + ], + }); + expect(diffDateTimes({ + date1, + date2, + country: 'FR', + businessTimezone: 'Europe/Paris', + includeEndDate: true, + includeWeekDays: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'], + includeHolidays: false, + businessStartHour: 9, + businessEndHour: 18, + })).to.deep.eq({ + startDate: date1, + endDate: date2, + businessDays: 21.959691358024692, + businessHours: 197.63722222222222, + businessSeconds: 711494, + businessSecondsFormatted: '8d 5h 38m 14s', + differenceFormatted: '21d 14h 38m 14s', + differenceSeconds: 1867094, + totalDifferenceFormatted: '30d 10h', + totalDifferenceSeconds: 2628000, + holidays, + ...daysInfos, + }); + expect(diffDateTimes({ + date1, + date2, + country: 'FR', + businessTimezone: 'Europe/Paris', + includeEndDate: true, + includeWeekDays: ['monday'], + includeHolidays: false, + businessStartHour: 9, + businessEndHour: 18, + })).to.deep.eq({ + startDate: date1, + endDate: date2, + businessDays: 4, + businessHours: 36, + businessSeconds: 129600, + businessSecondsFormatted: '1d 12h', + differenceFormatted: '4d', + differenceSeconds: 345600, + totalDifferenceFormatted: '30d 10h', + totalDifferenceSeconds: 2628000, + holidays, + ...daysInfos, + }); + }); + }); + describe('countCertainDays', () => { + it('compute right number of days', () => { + expect(countCertainDays([1, 3, 5], new Date(2014, 8, 1), new Date(2014, 8, 1))).toBe(1); + expect(countCertainDays([1, 3, 5], new Date(2014, 8, 1), new Date(2014, 8, 2))).toBe(1); + expect(countCertainDays([1, 3, 5], new Date(2014, 8, 1), new Date(2014, 8, 3))).toBe(2); + expect(countCertainDays([1, 3, 5], new Date(2014, 8, 1), new Date(2014, 8, 4))).toBe(2); + expect(countCertainDays([1, 3, 5], new Date(2014, 8, 1), new Date(2014, 8, 5))).toBe(3); + expect(countCertainDays([1, 3, 5], new Date(2014, 8, 1), new Date(2014, 8, 6))).toBe(3); + expect(countCertainDays([1, 3, 5], new Date(2014, 8, 1), new Date(2014, 8, 7))).toBe(3); + }); + }); + describe('datesByDays', () => { + it('compute week days dates', () => { + expect(datesByDays(DateTime.utc(2014, 8, 1), DateTime.utc(2014, 8, 31))).to.deep.eq({ + 1: [ + '2014-08-04', + '2014-08-11', + '2014-08-18', + '2014-08-25', + ], + 2: [ + '2014-08-05', + '2014-08-12', + '2014-08-19', + '2014-08-26', + ], + 3: [ + '2014-08-06', + '2014-08-13', + '2014-08-20', + '2014-08-27', + ], + 4: [ + '2014-08-07', + '2014-08-14', + '2014-08-21', + '2014-08-28', + ], + 5: [ + '2014-08-01', + '2014-08-08', + '2014-08-15', + '2014-08-22', + '2014-08-29', + ], + 6: [ + '2014-08-02', + '2014-08-09', + '2014-08-16', + '2014-08-23', + '2014-08-30', + ], + 7: [ + '2014-08-03', + '2014-08-10', + '2014-08-17', + '2014-08-24', + '2014-08-31', + ], + }); + }); + }); +}); diff --git a/src/tools/days-calculator/days-calculator.service.ts b/src/tools/days-calculator/days-calculator.service.ts new file mode 100644 index 00000000..afdbf54a --- /dev/null +++ b/src/tools/days-calculator/days-calculator.service.ts @@ -0,0 +1,161 @@ +import { DateTime, Interval } from 'luxon'; +import prettyMilliseconds from 'pretty-ms'; +import Holidays, { type HolidaysTypes } from 'date-holidays'; +import _ from 'lodash'; +import { BusinessTime, type Holiday } from './business-time-calculator'; + +interface DateTimeRange { + startDate: Date + endDate: Date + totalDifferenceSeconds: number + totalDifferenceFormatted: string + differenceSeconds: number + differenceFormatted: string + businessSeconds: number + businessSecondsFormatted: string + businessHours: number + businessDays: number + mondays: string[] + tuesdays: string[] + wednesdays: string[] + thursdays: string[] + fridays: string[] + saturdays: string[] + sundays: string[] + weekendDays: number + weekends: number + holidays: HolidaysTypes.Holiday[] +} + +export type Weekdays = 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday'; +export const allDays: Weekdays[] = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; +export const allWeekDays: Weekdays[] = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday']; + +export function diffDateTimes({ + date1, + date2, + country, state = undefined, region = undefined, + businessTimezone, + includeEndDate = true, + includeWeekDays = allWeekDays, + includeHolidays = true, + businessStartHour = 9, + businessEndHour = 18, + +}: { + date1: Date + date2: Date + country: string + state?: string + region?: string + includeEndDate?: boolean + includeWeekDays?: Array + includeHolidays?: boolean + businessStartHour: number + businessEndHour: number + businessTimezone: string +}): DateTimeRange { + function getHolidaysBetween(date1: DateTime, date2: DateTime) { + const startDateTime = date1.startOf('day'); + const endDateTime = date2.endOf('day'); + const hd = new Holidays(country, state || '', region || ''); + let holidays: Array = []; + for (let year = startDateTime.year; year <= endDateTime.year; year += 1) { + holidays = [...holidays, ...hd.getHolidays(year)]; + } + + const range = Interval.fromDateTimes(startDateTime, endDateTime); + return holidays.filter(h => range.contains(DateTime.fromJSDate(h.start))); + } + + const startDateTime = DateTime.fromJSDate(date1); + let endDateTime = DateTime.fromJSDate(date2); + if (!includeEndDate) { + endDateTime = endDateTime.minus({ days: 1 }).endOf('day'); + } + if (endDateTime < startDateTime) { + endDateTime = startDateTime; + } + + const holidays = getHolidaysBetween(startDateTime, endDateTime); + const holidaysDates = holidays.map(h => DateTime.fromJSDate(h.start).toFormat('dd/MM/yyyy') as Holiday); + + const differenceTimeComputer = new BusinessTime({ + businessDays: includeWeekDays, + businessTimezone, + holidays: includeHolidays ? holidaysDates : [], + businessHours: [0, 24], + }); + const businessTimeComputer = new BusinessTime({ + businessDays: includeWeekDays, + businessTimezone, + holidays: includeHolidays ? holidaysDates : [], + businessHours: [businessStartHour, businessEndHour], + }); + + const startEnd = { start: startDateTime, end: endDateTime }; + + const totalDifferenceSeconds = endDateTime.diff(startDateTime, 'seconds').toObject().seconds || 0; + const differenceSeconds = differenceTimeComputer.computeBusinessSecondsInInterval(startEnd); + const businessSeconds = businessTimeComputer.computeBusinessSecondsInInterval(startEnd); + const weekDaysDates = datesByDays(startDateTime, endDateTime); + const weekendDays = countCertainDays([6, 0], date1, date2); + return { + startDate: startDateTime.toJSDate(), + endDate: endDateTime.toJSDate(), + totalDifferenceSeconds, + totalDifferenceFormatted: prettyMilliseconds(totalDifferenceSeconds * 1000), + differenceSeconds, + differenceFormatted: prettyMilliseconds(differenceSeconds * 1000), + businessSeconds, + businessSecondsFormatted: prettyMilliseconds(businessSeconds * 1000), + businessHours: businessTimeComputer.computeBusinessHoursInInterval(startEnd), + businessDays: businessTimeComputer.computeBusinessDaysInInterval(startEnd), + mondays: weekDaysDates['1'] || [], + tuesdays: weekDaysDates['2'] || [], + wednesdays: weekDaysDates['3'] || [], + thursdays: weekDaysDates['4'] || [], + fridays: weekDaysDates['5'] || [], + saturdays: weekDaysDates['6'] || [], + sundays: weekDaysDates['7'] || [], + weekendDays, + weekends: Math.floor(weekendDays / 2), + holidays, + }; +} + +// days is an array of weekdays: 0 is Sunday, ..., 6 is Saturday +export function countCertainDays(days: Array<0 | 1 | 2 | 3 | 4 | 5 | 6>, d0: Date, d1: Date) { + const ndays = 1 + Math.round((d1.getTime() - d0.getTime()) / (24 * 3600 * 1000)); + const sum = function (a: number, b: number) { + return a + Math.floor((ndays + (d0.getDay() + 6 - b) % 7) / 7); + }; + return days.reduce(sum, 0); +} + +export function datesByDays(startDateTime: DateTime, endDateTime: DateTime) { + const dates = Interval.fromDateTimes(startDateTime.startOf('day'), endDateTime.endOf('day')).splitBy({ day: 1 }).map(d => d.start); + return _.chain(dates) + .groupBy(d => d?.weekday) + .map((dates, weekday) => ({ weekday, dates })) + .reduce((prev, curr) => ({ ...prev, [curr.weekday]: mapToJSDate(curr.dates) }), {} as { [weekday: string]: string[] }) + .value(); +} +function mapToJSDate(dates: (DateTime | null)[]): string[] { + return dates.map(d => d?.toISODate() || '').filter(d => d); +} + +export function getSupportedCountries() { + const hd = new Holidays(); + return Object.entries(hd.getCountries()).map(([code, name]) => ({ value: code, label: name })); +} + +export function getSupportedStates(country: string) { + const hd = new Holidays(); + return Object.entries(hd.getStates(country) || []).map(([code, name]) => ({ value: code, label: name })); +} + +export function getSupportedRegions(country: string, state: string) { + const hd = new Holidays(); + return Object.entries(hd.getRegions(country, state) || []).map(([code, name]) => ({ value: code, label: name })); +} diff --git a/src/tools/days-calculator/days-calculator.vue b/src/tools/days-calculator/days-calculator.vue new file mode 100644 index 00000000..73ef8888 --- /dev/null +++ b/src/tools/days-calculator/days-calculator.vue @@ -0,0 +1,176 @@ + + + diff --git a/src/tools/days-calculator/index.ts b/src/tools/days-calculator/index.ts new file mode 100644 index 00000000..b4c6217e --- /dev/null +++ b/src/tools/days-calculator/index.ts @@ -0,0 +1,12 @@ +import { Calendar } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'Days Calculator', + path: '/days-calculator', + description: 'Calculate days interval, holidays, difference, business times', + keywords: ['days', 'interval', 'month', 'year', 'difference', 'holidays', 'calculator'], + component: () => import('./days-calculator.vue'), + icon: Calendar, + createdAt: new Date('2024-08-15'), +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index 200b947f..67093881 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -2,9 +2,9 @@ import { tool as base64FileConverter } from './base64-file-converter'; import { tool as base64StringConverter } from './base64-string-converter'; import { tool as basicAuthGenerator } from './basic-auth-generator'; import { tool as emailNormalizer } from './email-normalizer'; - import { tool as asciiTextDrawer } from './ascii-text-drawer'; - +import { tool as daysCalculator } from './days-calculator'; +import { tool as dateDurationCalculator } from './date-duration-calculator'; import { tool as textToUnicode } from './text-to-unicode'; import { tool as safelinkDecoder } from './safelink-decoder'; import { tool as xmlToJson } from './xml-to-json'; @@ -176,7 +176,9 @@ export const toolsByCategory: ToolCategory[] = [ components: [ chronometer, temperatureConverter, + daysCalculator, durationCalculator, + dateDurationCalculator, benchmarkBuilder, ], }, From 5116d183e17c680501038282504d84d4030222a3 Mon Sep 17 00:00:00 2001 From: ShareVB Date: Wed, 1 Jan 2025 19:16:43 +0100 Subject: [PATCH 5/8] fix: handle (.net) timespan format and submilliseconds format: d.hh:mm:ss.fff and prettify in submilliseconds --- .../duration-calculator.service.test.ts | 28 ++++++++++++-- .../duration-calculator.service.ts | 38 ++++++++++--------- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/src/tools/duration-calculator/duration-calculator.service.test.ts b/src/tools/duration-calculator/duration-calculator.service.test.ts index 9035587a..8db3c061 100644 --- a/src/tools/duration-calculator/duration-calculator.service.test.ts +++ b/src/tools/duration-calculator/duration-calculator.service.test.ts @@ -123,11 +123,11 @@ describe('duration-calculator', () => { iso8601Duration: 'P0Y0M0DT2H40M15S', milliseconds: 9615125, minutes: 160.25208333333333, - prettified: '2h 40m 15.1s', + 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.1 seconds', + prettifiedVerbose: '2 hours 40 minutes 15 seconds 125 milliseconds', seconds: 9615.125, weeks: 0.01589802414021164, years: 0.00030489361364789447, @@ -178,11 +178,11 @@ describe('duration-calculator', () => { iso8601Duration: 'P0Y0M0DT12H20M0S', milliseconds: 44400020.3, minutes: 740.0003383333333, - prettified: '12h 20m', + prettified: '12h 20m 20ms 300µs', prettifiedColonNotation: '12:20:00', prettifiedDaysColon: '12:20:00.20.299999997019768', prettifiedHoursColon: '12:20:00.20.299999997019768', - prettifiedVerbose: '12 hours 20 minutes', + prettifiedVerbose: '12 hours 20 minutes 20 milliseconds 300 microseconds', seconds: 44400.0203, weeks: 0.07341273197751322, years: 0.0014079154077879248, @@ -309,5 +309,25 @@ describe('duration-calculator', () => { }, }); }); + 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: 'P0Y0M2DT0H0M0S', + 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, + }, + }); + }); }); }); diff --git a/src/tools/duration-calculator/duration-calculator.service.ts b/src/tools/duration-calculator/duration-calculator.service.ts index 98dc4c85..dd47de17 100644 --- a/src/tools/duration-calculator/duration-calculator.service.ts +++ b/src/tools/duration-calculator/duration-calculator.service.ts @@ -61,22 +61,24 @@ export function computeDuration(s: string): { } function convertDurationMS(s: string): number | undefined { - const hoursHandled = s.replace(/\b(\d+):(\d+)(?::(\d+)(?:\.(\d+))?)?/g, (_, h, m, s, ms) => { - const timeArr: string[] = []; - const addPart = (part: string, unit: string) => { - const num = Number.parseInt(part, 10); - if (Number.isNaN(num)) { - return; - } + const hoursHandled = s.replace(/\b(?:(\d+)\.)?(\d+):(\d+)(?::(\d+)(?:\.(\d+))?)?\b/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(h, 'h'); - addPart(m, 'm'); - addPart(s, 's'); - addPart(ms, 'ms'); - return timeArr.join(' '); - }); + timeArr.push(`${num}${unit}`); + }; + addPart(d, 'd'); + addPart(h, 'h'); + addPart(m, 'm'); + addPart(s, 's'); + addPart(ms, 'ms'); + return timeArr.join(' '); + }); if (!hoursHandled) { return 0; } @@ -95,9 +97,9 @@ function convertDurationMS(s: string): number | undefined { function prepareDurationResult(durationMS: any): ConvertedDuration { const dateFnsDuration = intervalToDuration({ start: 0, end: durationMS }); return { - prettified: prettyMilliseconds(durationMS), - prettifiedVerbose: prettyMilliseconds(durationMS, { verbose: true }), - prettifiedColonNotation: prettyMilliseconds(durationMS, { colonNotation: true }), + 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), From cfa250220105406b5859db6b60831dc34901d705 Mon Sep 17 00:00:00 2001 From: ShareVB Date: Wed, 1 Jan 2025 21:54:11 +0100 Subject: [PATCH 6/8] fix: fix handling of milliseconds in parsing and in formatting iso duration in parsing of timestamp --- .../command-palette/command-palette.vue | 2 +- .../duration-calculator.service.test.ts | 68 +++++++++++++++---- .../duration-calculator.service.ts | 33 ++++----- src/ui/c-select/c-select.vue | 2 +- src/ui/c-table/c-table.vue | 2 +- 5 files changed, 73 insertions(+), 34 deletions(-) diff --git a/src/modules/command-palette/command-palette.vue b/src/modules/command-palette/command-palette.vue index bceef5cd..5d953fee 100644 --- a/src/modules/command-palette/command-palette.vue +++ b/src/modules/command-palette/command-palette.vue @@ -128,7 +128,7 @@ function activateOption(option: PaletteOption) {
-
+
{{ category }}
diff --git a/src/tools/duration-calculator/duration-calculator.service.test.ts b/src/tools/duration-calculator/duration-calculator.service.test.ts index 8db3c061..80e50a27 100644 --- a/src/tools/duration-calculator/duration-calculator.service.test.ts +++ b/src/tools/duration-calculator/duration-calculator.service.test.ts @@ -24,6 +24,8 @@ 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: [], @@ -120,7 +122,7 @@ describe('duration-calculator', () => { total: { days: 0.11128616898148148, hours: 2.6708680555555557, - iso8601Duration: 'P0Y0M0DT2H40M15S', + iso8601Duration: 'P0Y0M0DT2H40M15.125S', milliseconds: 9615125, minutes: 160.25208333333333, prettified: '2h 40m 15s 125ms', @@ -173,19 +175,19 @@ describe('duration-calculator', () => { expect(computeDuration('P4DT12H20M20.3S')).to.deep.eq({ errors: [], total: { - days: 0.5138891238425926, - hours: 12.333338972222222, - iso8601Duration: 'P0Y0M0DT12H20M0S', - milliseconds: 44400020.3, - minutes: 740.0003383333333, - prettified: '12h 20m 20ms 300µs', - prettifiedColonNotation: '12:20:00', - prettifiedDaysColon: '12:20:00.20.299999997019768', - prettifiedHoursColon: '12:20:00.20.299999997019768', - prettifiedVerbose: '12 hours 20 minutes 20 milliseconds 300 microseconds', - seconds: 44400.0203, - weeks: 0.07341273197751322, - years: 0.0014079154077879248, + 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({ @@ -315,7 +317,7 @@ describe('duration-calculator', () => { total: { days: 2.000001446759259, hours: 48.000034722222225, - iso8601Duration: 'P0Y0M2DT0H0M0S', + iso8601Duration: 'P0Y0M2DT0H0M0.125S', milliseconds: 172800125, minutes: 2880.0020833333333, prettified: '2d 125ms', @@ -328,6 +330,42 @@ describe('duration-calculator', () => { 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, + }, + }); }); }); }); diff --git a/src/tools/duration-calculator/duration-calculator.service.ts b/src/tools/duration-calculator/duration-calculator.service.ts index dd47de17..254e3afb 100644 --- a/src/tools/duration-calculator/duration-calculator.service.ts +++ b/src/tools/duration-calculator/duration-calculator.service.ts @@ -23,7 +23,7 @@ interface DurationLine { rawLine: string cleanedDuration: string sign: number - durationMS: number | undefined + durationMS: number | null isValid: boolean } @@ -33,14 +33,14 @@ export function computeDuration(s: 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*/, ''); + const cleanedDuration = l.replace(/^\s*[+-]\s*/, '').replace(/\s*#.*$/, ''); // NOSONAR const durationMS = convertDurationMS(cleanedDuration); return { rawLine: l, cleanedDuration, sign: isNeg ? -1 : 1, durationMS, - isValid: typeof durationMS !== 'undefined', + isValid: durationMS !== null, }; }); @@ -60,8 +60,8 @@ export function computeDuration(s: string): { }; } -function convertDurationMS(s: string): number | undefined { - const hoursHandled = s.replace(/\b(?:(\d+)\.)?(\d+):(\d+)(?::(\d+)(?:\.(\d+))?)?\b/g, +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) => { @@ -76,26 +76,27 @@ function convertDurationMS(s: string): number | undefined { addPart(h, 'h'); addPart(m, 'm'); addPart(s, 's'); - addPart(ms, 'ms'); + addPart(ms?.padEnd(3, '0'), 'ms'); return timeArr.join(' '); }); if (!hoursHandled) { return 0; } - let parsedDuration = parse(hoursHandled); - if (parsedDuration !== 0 && !parsedDuration) { - try { - parsedDuration = iso8601Duration.toMilliseconds(iso8601Duration.parse(hoursHandled)); - } - catch (_) { - return undefined; - } + try { + return iso8601Duration.toMilliseconds(iso8601Duration.parse(hoursHandled)); + } + catch (_) { + const result = parse(hoursHandled); + if (typeof result === 'undefined') { + return null; + } + return result; } - return parsedDuration; } -function prepareDurationResult(durationMS: any): ConvertedDuration { +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 }), diff --git a/src/ui/c-select/c-select.vue b/src/ui/c-select/c-select.vue index 7b3607c9..38e2eb8f 100644 --- a/src/ui/c-select/c-select.vue +++ b/src/ui/c-select/c-select.vue @@ -151,7 +151,7 @@ function onSearchInput() { >
- + {{ selectedOption.label }} diff --git a/src/ui/c-table/c-table.vue b/src/ui/c-table/c-table.vue index ef569890..43293ab3 100644 --- a/src/ui/c-table/c-table.vue +++ b/src/ui/c-table/c-table.vue @@ -39,7 +39,7 @@ const headers = computed(() => {