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',