diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index 8bf2251..8296def 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,13 +4,8 @@
-
-
-
-
-
-
-
+
+
@@ -27,78 +22,85 @@
- {
+ "history": [
{
- "assignee": "iib0011"
+ "assignee": "iib0011"
},
{
- "state": "OPEN"
+ "state": "OPEN"
}
],
- "lastFilter": {
- "state": "OPEN"
+ "lastFilter": {
+ "state": "OPEN"
}
-}]]>
-
+ {
+ "prStates": [
{
- "id": {
- "id": "PR_kwDOMJIfts51PkS9",
- "number": 22
+ "id": {
+ "id": "PR_kwDOMJIfts51PkS9",
+ "number": 22
},
- "lastSeen": 1741207144695
+ "lastSeen": 1741207144695
},
{
- "id": {
- "id": "PR_kwDOMJIfts6NiNYl",
- "number": 32
+ "id": {
+ "id": "PR_kwDOMJIfts6NiNYl",
+ "number": 32
},
- "lastSeen": 1741209723869
+ "lastSeen": 1741209723869
},
{
- "id": {
- "id": "PR_kwDOMJIfts6Nheyd",
- "number": 31
+ "id": {
+ "id": "PR_kwDOMJIfts6Nheyd",
+ "number": 31
},
- "lastSeen": 1741213371410
+ "lastSeen": 1741213371410
},
{
- "id": {
- "id": "PR_kwDOMJIfts6NmRBs",
- "number": 33
+ "id": {
+ "id": "PR_kwDOMJIfts6NmRBs",
+ "number": 33
},
- "lastSeen": 1741282429036
+ "lastSeen": 1741282429036
},
{
- "id": {
- "id": "PR_kwDOMJIfts5zyFTs",
- "number": 15
+ "id": {
+ "id": "PR_kwDOMJIfts5zyFTs",
+ "number": 15
},
- "lastSeen": 1741535540953
+ "lastSeen": 1741535540953
},
{
- "id": {
- "id": "PR_kwDOMJIfts6QQB3c",
- "number": 59
+ "id": {
+ "id": "PR_kwDOMJIfts6QQB3c",
+ "number": 59
},
- "lastSeen": 1743018960900
+ "lastSeen": 1743018960900
},
{
- "id": {
- "id": "PR_kwDOMJIfts6QMPEg",
- "number": 58
+ "id": {
+ "id": "PR_kwDOMJIfts6QMPEg",
+ "number": 58
},
- "lastSeen": 1743019452983
+ "lastSeen": 1743019452983
+ },
+ {
+ "id": {
+ "id": "PR_kwDOMJIfts6QZvRI",
+ "number": 61
+ },
+ "lastSeen": 1743103196866
}
]
-}]]>
+}
{
"selectedUrlAndAccountId": {
"url": "https://github.com/iib0011/omni-tools.git",
@@ -150,6 +152,7 @@
"Vitest.removeDuplicateLines function.newlines option.should filter newlines when newlines is set to filter.executor": "Run",
"Vitest.replaceText function (regexp mode).should return the original text when passed an invalid regexp.executor": "Run",
"Vitest.replaceText function.executor": "Run",
+ "Vitest.timeBetweenDates.executor": "Run",
"git-widget-placeholder": "main",
"ignore.virus.scanning.warn.message": "true",
"kotlin-language-version-configured": "true",
@@ -201,17 +204,17 @@
-
-
+
+
-
+
+
-
-
+
+
-
-
+
@@ -242,6 +245,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -258,30 +274,20 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
-
@@ -365,39 +371,9 @@
-
-
-
-
- 1740503419102
-
-
-
- 1740503419102
-
-
-
- 1740504051051
-
-
-
- 1740504051051
-
-
-
- 1740504100676
-
-
-
- 1740504100676
-
-
-
- 1740505390205
-
-
-
- 1740505390205
+
+
+
@@ -759,7 +735,39 @@
1743020690384
-
+
+
+ 1743022260639
+
+
+
+ 1743022260639
+
+
+
+ 1743051792459
+
+
+
+ 1743051792459
+
+
+
+ 1743052111988
+
+
+
+ 1743052111988
+
+
+
+ 1743106796406
+
+
+
+ 1743106796406
+
+
@@ -806,10 +814,6 @@
-
-
-
-
@@ -831,7 +835,11 @@
-
+
+
+
+
+
diff --git a/README.md b/README.md
index 4b25635..fb1a1c5 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-
+
diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx
index d5ff305..6406b45 100644
--- a/src/components/Hero.tsx
+++ b/src/components/Hero.tsx
@@ -19,9 +19,9 @@ const exampleTools: { label: string; url: string }[] = [
{ label: 'Sort a list', url: '/list/sort' },
{ label: 'Compress PNG', url: '/png/compress-png' },
{ label: 'Split a text', url: '/string/split' },
- { label: 'Calculate number sum', url: '/number/sum' },
- { label: 'Shuffle a list', url: '/list/shuffle' },
- { label: 'Change colors in image', url: '/png/change-colors-in-png' }
+ { label: 'Split PDF', url: '/pdf/split-pdf' },
+ { label: 'Trim video', url: '/video/trim' },
+ { label: 'Calculate number sum', url: '/number/sum' }
];
export default function Hero() {
const [inputValue, setInputValue] = useState('');
diff --git a/src/components/allTools/ToolCard.tsx b/src/components/allTools/ToolCard.tsx
index 0767e18..c4047c8 100644
--- a/src/components/allTools/ToolCard.tsx
+++ b/src/components/allTools/ToolCard.tsx
@@ -21,7 +21,11 @@ export default function ToolCard({
borderColor: '#5581b5',
color: '#fff',
boxShadow: '6px 6px 12px #b8b9be, -6px -6px 12px #fff',
- cursor: 'pointer'
+ cursor: 'pointer',
+ height: '100%',
+ '&:hover': {
+ transform: 'scale(1.05)'
+ }
}}
>
diff --git a/src/pages/tools/pdf/index.ts b/src/pages/tools/pdf/index.ts
index e93a8c1..380e593 100644
--- a/src/pages/tools/pdf/index.ts
+++ b/src/pages/tools/pdf/index.ts
@@ -1,4 +1,5 @@
+import { tool as pdfRotatePdf } from './rotate-pdf/meta';
import { meta as splitPdfMeta } from './split-pdf/meta';
import { DefinedTool } from '@tools/defineTool';
-export const pdfTools: DefinedTool[] = [splitPdfMeta];
+export const pdfTools: DefinedTool[] = [splitPdfMeta, pdfRotatePdf];
diff --git a/src/pages/tools/pdf/rotate-pdf/index.tsx b/src/pages/tools/pdf/rotate-pdf/index.tsx
new file mode 100644
index 0000000..4152269
--- /dev/null
+++ b/src/pages/tools/pdf/rotate-pdf/index.tsx
@@ -0,0 +1,242 @@
+import { Box, FormControlLabel, Switch, Typography } from '@mui/material';
+import React, { useEffect, useState } from 'react';
+import ToolContent from '@components/ToolContent';
+import { ToolComponentProps } from '@tools/defineTool';
+import ToolPdfInput from '@components/input/ToolPdfInput';
+import ToolFileResult from '@components/result/ToolFileResult';
+import { CardExampleType } from '@components/examples/ToolExamples';
+import { PDFDocument } from 'pdf-lib';
+import { InitialValuesType, RotationAngle } from './types';
+import { parsePageRanges, rotatePdf } from './service';
+import SimpleRadio from '@components/options/SimpleRadio';
+import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
+
+const initialValues: InitialValuesType = {
+ rotationAngle: 90,
+ applyToAllPages: true,
+ pageRanges: ''
+};
+
+const exampleCards: CardExampleType[] = [
+ {
+ title: 'Rotate All Pages 90°',
+ description: 'Rotate all pages in the document 90 degrees clockwise',
+ sampleText: '',
+ sampleResult: '',
+ sampleOptions: {
+ rotationAngle: 90,
+ applyToAllPages: true,
+ pageRanges: ''
+ }
+ },
+ {
+ title: 'Rotate Specific Pages 180°',
+ description: 'Rotate only pages 1 and 3 by 180 degrees',
+ sampleText: '',
+ sampleResult: '',
+ sampleOptions: {
+ rotationAngle: 180,
+ applyToAllPages: false,
+ pageRanges: '1,3'
+ }
+ },
+ {
+ title: 'Rotate Page Range 270°',
+ description: 'Rotate pages 2 through 5 by 270 degrees',
+ sampleText: '',
+ sampleResult: '',
+ sampleOptions: {
+ rotationAngle: 270,
+ applyToAllPages: false,
+ pageRanges: '2-5'
+ }
+ }
+];
+
+export default function RotatePdf({
+ title,
+ longDescription
+}: ToolComponentProps) {
+ const [input, setInput] = useState(null);
+ const [result, setResult] = useState(null);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [totalPages, setTotalPages] = useState(0);
+ const [pageRangePreview, setPageRangePreview] = useState('');
+
+ // Get the total number of pages when a PDF is uploaded
+ useEffect(() => {
+ const getPdfInfo = async () => {
+ if (!input) {
+ setTotalPages(0);
+ return;
+ }
+
+ try {
+ const arrayBuffer = await input.arrayBuffer();
+ const pdf = await PDFDocument.load(arrayBuffer);
+ setTotalPages(pdf.getPageCount());
+ } catch (error) {
+ console.error('Error getting PDF info:', error);
+ setTotalPages(0);
+ }
+ };
+
+ getPdfInfo();
+ }, [input]);
+
+ const onValuesChange = (values: InitialValuesType) => {
+ const { pageRanges, applyToAllPages } = values;
+
+ if (applyToAllPages) {
+ setPageRangePreview(
+ totalPages > 0 ? `All ${totalPages} pages will be rotated` : ''
+ );
+ return;
+ }
+
+ if (!totalPages || !pageRanges?.trim()) {
+ setPageRangePreview('');
+ return;
+ }
+
+ try {
+ const count = parsePageRanges(pageRanges, totalPages).length;
+ setPageRangePreview(
+ `${count} page${count !== 1 ? 's' : ''} will be rotated`
+ );
+ } catch (error) {
+ setPageRangePreview('');
+ }
+ };
+
+ const compute = async (values: InitialValuesType, input: File | null) => {
+ if (!input) return;
+
+ try {
+ setIsProcessing(true);
+ const rotatedPdf = await rotatePdf(input, values);
+ setResult(rotatedPdf);
+ } catch (error) {
+ throw new Error('Error rotating PDF: ' + error);
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+ const angleOptions: { value: RotationAngle; label: string }[] = [
+ { value: 90, label: '90° Clockwise' },
+ { value: 180, label: '180° (Upside down)' },
+ { value: 270, label: '270° (90° Counter-clockwise)' }
+ ];
+ return (
+
+ }
+ resultComponent={
+
+ }
+ getGroups={({ values, updateField }) => [
+ {
+ title: 'Rotation Settings',
+ component: (
+
+
+ Rotation Angle
+
+ {angleOptions.map((angleOption) => (
+ {
+ updateField('rotationAngle', angleOption.value);
+ }}
+ />
+ ))}
+
+
+ {
+ updateField('applyToAllPages', e.target.checked);
+ }}
+ />
+ }
+ label="Apply to all pages"
+ />
+
+
+ {!values.applyToAllPages && (
+
+ {totalPages > 0 && (
+
+ PDF has {totalPages} page{totalPages !== 1 ? 's' : ''}
+
+ )}
+ {
+ updateField('pageRanges', val);
+ }}
+ description={
+ 'Enter page numbers or ranges separated by commas (e.g., 1,3,5-7)'
+ }
+ placeholder={'e.g., 1,5-8'}
+ />
+ {pageRangePreview && (
+
+ {pageRangePreview}
+
+ )}
+
+ )}
+
+ )
+ }
+ ]}
+ onValuesChange={onValuesChange}
+ toolInfo={{
+ title: 'How to Use the Rotate PDF Tool',
+ description: `This tool allows you to rotate pages in a PDF document. You can rotate all pages or specify individual pages to rotate.
+
+Choose a rotation angle:
+- 90° Clockwise
+- 180° (Upside down)
+- 270° (90° Counter-clockwise)
+
+To rotate specific pages:
+1. Uncheck "Apply to all pages"
+2. Enter page numbers or ranges separated by commas (e.g., 1,3,5-7)
+
+Examples:
+- "1,5,9" rotates pages 1, 5, and 9
+- "1-5" rotates pages 1 through 5
+- "1,3-5,8-10" rotates pages 1, 3, 4, 5, 8, 9, and 10
+
+${longDescription}`
+ }}
+ />
+ );
+}
diff --git a/src/pages/tools/pdf/rotate-pdf/meta.ts b/src/pages/tools/pdf/rotate-pdf/meta.ts
new file mode 100644
index 0000000..8dc71a1
--- /dev/null
+++ b/src/pages/tools/pdf/rotate-pdf/meta.ts
@@ -0,0 +1,14 @@
+import { defineTool } from '@tools/defineTool';
+import { lazy } from 'react';
+
+export const tool = defineTool('pdf', {
+ name: 'Rotate PDF',
+ path: 'rotate-pdf',
+ icon: 'carbon:rotate',
+ description: 'Rotate PDF pages by 90, 180, or 270 degrees',
+ shortDescription: 'Rotate pages in a PDF document',
+ keywords: ['pdf', 'rotate', 'rotation', 'document', 'pages', 'orientation'],
+ longDescription:
+ 'Change the orientation of PDF pages by rotating them 90, 180, or 270 degrees. Useful for fixing incorrectly scanned documents or preparing PDFs for printing.',
+ component: lazy(() => import('./index'))
+});
diff --git a/src/pages/tools/pdf/rotate-pdf/rotate-pdf.service.test.ts b/src/pages/tools/pdf/rotate-pdf/rotate-pdf.service.test.ts
new file mode 100644
index 0000000..4fb2694
--- /dev/null
+++ b/src/pages/tools/pdf/rotate-pdf/rotate-pdf.service.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest';
+import { parsePageRanges } from './service';
+
+describe('rotate-pdf', () => {
+ describe('parsePageRanges', () => {
+ it('should return all pages when pageRanges is empty', () => {
+ const result = parsePageRanges('', 5);
+ expect(result).toEqual([1, 2, 3, 4, 5]);
+ });
+
+ it('should parse single page numbers', () => {
+ const result = parsePageRanges('1,3,5', 5);
+ expect(result).toEqual([1, 3, 5]);
+ });
+
+ it('should parse page ranges', () => {
+ const result = parsePageRanges('2-4', 5);
+ expect(result).toEqual([2, 3, 4]);
+ });
+
+ it('should parse mixed page numbers and ranges', () => {
+ const result = parsePageRanges('1,3-5', 5);
+ expect(result).toEqual([1, 3, 4, 5]);
+ });
+
+ it('should ignore invalid page numbers', () => {
+ const result = parsePageRanges('1,8,3', 5);
+ expect(result).toEqual([1, 3]);
+ });
+
+ it('should handle whitespace', () => {
+ const result = parsePageRanges(' 1, 3 - 5 ', 5);
+ expect(result).toEqual([1, 3, 4, 5]);
+ });
+ });
+});
diff --git a/src/pages/tools/pdf/rotate-pdf/service.ts b/src/pages/tools/pdf/rotate-pdf/service.ts
new file mode 100644
index 0000000..ee72c63
--- /dev/null
+++ b/src/pages/tools/pdf/rotate-pdf/service.ts
@@ -0,0 +1,82 @@
+import { degrees, PDFDocument } from 'pdf-lib';
+import { InitialValuesType } from './types';
+
+/**
+ * Parses a page range string and returns an array of page numbers
+ * @param pageRangeStr String like "1,3-5,7" to extract pages 1, 3, 4, 5, and 7
+ * @param totalPages Total number of pages in the PDF
+ * @returns Array of page numbers to extract
+ */
+export function parsePageRanges(
+ pageRangeStr: string,
+ totalPages: number
+): number[] {
+ if (!pageRangeStr.trim()) {
+ return Array.from({ length: totalPages }, (_, i) => i + 1);
+ }
+
+ const pageNumbers = new Set();
+ const ranges = pageRangeStr.split(',');
+
+ for (const range of ranges) {
+ const trimmedRange = range.trim();
+
+ if (trimmedRange.includes('-')) {
+ const [start, end] = trimmedRange.split('-').map(Number);
+ if (!isNaN(start) && !isNaN(end)) {
+ // Handle both forward and reversed ranges
+ const normalizedStart = Math.min(start, end);
+ const normalizedEnd = Math.max(start, end);
+
+ for (
+ let i = Math.max(1, normalizedStart);
+ i <= Math.min(totalPages, normalizedEnd);
+ i++
+ ) {
+ pageNumbers.add(i);
+ }
+ }
+ } else {
+ const pageNum = parseInt(trimmedRange, 10);
+ if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= totalPages) {
+ pageNumbers.add(pageNum);
+ }
+ }
+ }
+
+ return [...pageNumbers].sort((a, b) => a - b);
+}
+
+/**
+ * Rotates pages in a PDF file
+ * @param pdfFile The input PDF file
+ * @param options Options including rotation angle and page selection
+ * @returns Promise resolving to a new PDF file with rotated pages
+ */
+export async function rotatePdf(
+ pdfFile: File,
+ options: InitialValuesType
+): Promise {
+ const { rotationAngle, applyToAllPages, pageRanges } = options;
+
+ const arrayBuffer = await pdfFile.arrayBuffer();
+ const pdfDoc = await PDFDocument.load(arrayBuffer);
+ const totalPages = pdfDoc.getPageCount();
+
+ // Determine which pages to rotate
+ const pagesToRotate = applyToAllPages
+ ? Array.from({ length: totalPages }, (_, i) => i + 1)
+ : parsePageRanges(pageRanges, totalPages);
+
+ // Apply rotation to selected pages
+ for (const pageNum of pagesToRotate) {
+ const page = pdfDoc.getPage(pageNum - 1);
+ page.setRotation(degrees(rotationAngle));
+ }
+
+ // Save the modified PDF
+ const modifiedPdfBytes = await pdfDoc.save();
+ const newFileName = pdfFile.name.replace('.pdf', '-rotated.pdf');
+
+ return new File([modifiedPdfBytes], newFileName, { type: 'application/pdf' });
+}
diff --git a/src/pages/tools/pdf/rotate-pdf/types.ts b/src/pages/tools/pdf/rotate-pdf/types.ts
new file mode 100644
index 0000000..c32bbc5
--- /dev/null
+++ b/src/pages/tools/pdf/rotate-pdf/types.ts
@@ -0,0 +1,7 @@
+export type RotationAngle = 90 | 180 | 270;
+
+export type InitialValuesType = {
+ rotationAngle: RotationAngle;
+ applyToAllPages: boolean;
+ pageRanges: string;
+};
diff --git a/src/pages/tools/time/index.ts b/src/pages/tools/time/index.ts
index f229937..9b80e65 100644
--- a/src/pages/tools/time/index.ts
+++ b/src/pages/tools/time/index.ts
@@ -1,3 +1,4 @@
+import { tool as timeBetweenDates } from './time-between-dates/meta';
import { tool as daysDoHours } from './convert-days-to-hours/meta';
import { tool as hoursToDays } from './convert-hours-to-days/meta';
import { tool as convertSecondsToTime } from './convert-seconds-to-time/meta';
@@ -9,5 +10,6 @@ export const timeTools = [
hoursToDays,
convertSecondsToTime,
convertTimetoSeconds,
- truncateClockTime
+ truncateClockTime,
+ timeBetweenDates
];
diff --git a/src/pages/tools/time/time-between-dates/index.tsx b/src/pages/tools/time/time-between-dates/index.tsx
new file mode 100644
index 0000000..a02ee70
--- /dev/null
+++ b/src/pages/tools/time/time-between-dates/index.tsx
@@ -0,0 +1,250 @@
+import { Box, Paper, Typography } from '@mui/material';
+import React, { useState } from 'react';
+import ToolContent from '@components/ToolContent';
+import TextFieldWithDesc from '@components/options/TextFieldWithDesc';
+import SelectWithDesc from '@components/options/SelectWithDesc';
+import {
+ calculateTimeBetweenDates,
+ formatTimeWithLargestUnit,
+ getTimeWithTimezone,
+ unitHierarchy
+} from './service';
+import * as Yup from 'yup';
+import { CardExampleType } from '@components/examples/ToolExamples';
+
+type TimeUnit =
+ | 'milliseconds'
+ | 'seconds'
+ | 'minutes'
+ | 'hours'
+ | 'days'
+ | 'months'
+ | 'years';
+
+type InitialValuesType = {
+ startDate: string;
+ startTime: string;
+ endDate: string;
+ endTime: string;
+ startTimezone: string;
+ endTimezone: string;
+};
+
+const initialValues: InitialValuesType = {
+ startDate: new Date().toISOString().split('T')[0],
+ startTime: '00:00',
+ endDate: new Date().toISOString().split('T')[0],
+ endTime: '12:00',
+ startTimezone: 'local',
+ endTimezone: 'local'
+};
+
+const validationSchema = Yup.object({
+ startDate: Yup.string().required('Start date is required'),
+ startTime: Yup.string().required('Start time is required'),
+ endDate: Yup.string().required('End date is required'),
+ endTime: Yup.string().required('End time is required'),
+ startTimezone: Yup.string(),
+ endTimezone: Yup.string()
+});
+
+const timezoneOptions = [
+ { value: 'local', label: 'Local Time' },
+ ...Intl.supportedValuesOf('timeZone')
+ .map((tz) => {
+ const formatter = new Intl.DateTimeFormat('en', {
+ timeZone: tz,
+ timeZoneName: 'shortOffset'
+ });
+
+ const offset =
+ formatter
+ .formatToParts(new Date())
+ .find((part) => part.type === 'timeZoneName')?.value || '';
+
+ return {
+ value: offset.replace('UTC', 'GMT'),
+ label: `${offset.replace('UTC', 'GMT')} (${tz})`
+ };
+ })
+ .sort((a, b) =>
+ a.value.localeCompare(b.value, undefined, { numeric: true })
+ )
+];
+
+const exampleCards: CardExampleType[] = [
+ {
+ title: 'One Year Difference',
+ description: 'Calculate the time between dates that are one year apart',
+ sampleOptions: {
+ startDate: '2023-01-01',
+ startTime: '12:00',
+ endDate: '2024-01-01',
+ endTime: '12:00',
+ startTimezone: 'local',
+ endTimezone: 'local'
+ },
+ sampleResult: '1 year'
+ },
+ {
+ title: 'Different Timezones',
+ description: 'Calculate the time difference between New York and London',
+ sampleOptions: {
+ startDate: '2023-01-01',
+ startTime: '12:00',
+ endDate: '2023-01-01',
+ endTime: '12:00',
+ startTimezone: 'GMT-5',
+ endTimezone: 'GMT'
+ },
+ sampleResult: '5 hours'
+ },
+ {
+ title: 'Detailed Time Breakdown',
+ description: 'Show a detailed breakdown of a time difference',
+ sampleOptions: {
+ startDate: '2023-01-01',
+ startTime: '09:30',
+ endDate: '2023-01-03',
+ endTime: '14:45',
+ startTimezone: 'local',
+ endTimezone: 'local'
+ },
+ sampleResult: '2 days, 5 hours, 15 minutes'
+ }
+];
+
+export default function TimeBetweenDates() {
+ const [result, setResult] = useState('');
+
+ return (
+
+
+ {result}
+
+
+ ) : null
+ }
+ initialValues={initialValues}
+ validationSchema={validationSchema}
+ exampleCards={exampleCards}
+ toolInfo={{
+ title: 'Time Between Dates Calculator',
+ description:
+ 'Calculate the exact time difference between two dates and times, with support for different timezones. This tool provides a detailed breakdown of the time difference in various units (years, months, days, hours, minutes, and seconds).'
+ }}
+ getGroups={({ values, updateField }) => [
+ {
+ title: 'Start Date & Time',
+ component: (
+
+ updateField('startDate', val)}
+ type="date"
+ />
+ updateField('startTime', val)}
+ type="time"
+ />
+ updateField('startTimezone', val)}
+ options={timezoneOptions}
+ />
+
+ )
+ },
+ {
+ title: 'End Date & Time',
+ component: (
+
+ updateField('endDate', val)}
+ type="date"
+ />
+ updateField('endTime', val)}
+ type="time"
+ />
+ updateField('endTimezone', val)}
+ options={timezoneOptions}
+ />
+
+ )
+ }
+ ]}
+ compute={(values) => {
+ try {
+ const startDateTime = getTimeWithTimezone(
+ values.startDate,
+ values.startTime,
+ values.startTimezone
+ );
+
+ const endDateTime = getTimeWithTimezone(
+ values.endDate,
+ values.endTime,
+ values.endTimezone
+ );
+
+ // Calculate time difference
+ const difference = calculateTimeBetweenDates(
+ startDateTime,
+ endDateTime
+ );
+
+ // Auto-determine the best unit to display based on the time difference
+ const bestUnit: TimeUnit =
+ unitHierarchy.find((unit) => difference[unit] > 0) ||
+ 'milliseconds';
+
+ const formattedDifference = formatTimeWithLargestUnit(
+ difference,
+ bestUnit
+ );
+
+ setResult(formattedDifference);
+ } catch (error) {
+ setResult(
+ `Error: ${
+ error instanceof Error
+ ? error.message
+ : 'Failed to calculate time difference'
+ }`
+ );
+ }
+ }}
+ />
+ );
+}
diff --git a/src/pages/tools/time/time-between-dates/meta.ts b/src/pages/tools/time/time-between-dates/meta.ts
new file mode 100644
index 0000000..a94f6ec
--- /dev/null
+++ b/src/pages/tools/time/time-between-dates/meta.ts
@@ -0,0 +1,22 @@
+import { defineTool } from '@tools/defineTool';
+import { lazy } from 'react';
+
+export const tool = defineTool('time', {
+ name: 'Time Between Dates',
+ path: 'time-between-dates',
+ icon: 'tabler:clock-minus',
+ description:
+ 'Calculate the exact time difference between two dates and times, with support for different timezones. This tool provides a detailed breakdown of the time difference in various units (years, months, days, hours, minutes, and seconds).',
+ shortDescription:
+ 'Calculate the precise time duration between two dates with timezone support.',
+ keywords: [
+ 'time',
+ 'dates',
+ 'difference',
+ 'duration',
+ 'calculator',
+ 'timezones',
+ 'interval'
+ ],
+ component: lazy(() => import('./index'))
+});
diff --git a/src/pages/tools/time/time-between-dates/service.ts b/src/pages/tools/time/time-between-dates/service.ts
new file mode 100644
index 0000000..785f392
--- /dev/null
+++ b/src/pages/tools/time/time-between-dates/service.ts
@@ -0,0 +1,129 @@
+export const unitHierarchy = [
+ 'years',
+ 'months',
+ 'days',
+ 'hours',
+ 'minutes',
+ 'seconds',
+ 'milliseconds'
+] as const;
+
+export type TimeUnit = (typeof unitHierarchy)[number];
+export type TimeDifference = Record;
+
+export const calculateTimeBetweenDates = (
+ startDate: Date,
+ endDate: Date
+): TimeDifference => {
+ if (endDate < startDate) {
+ const temp = startDate;
+ startDate = endDate;
+ endDate = temp;
+ }
+
+ const milliseconds = endDate.getTime() - startDate.getTime();
+ const seconds = Math.floor(milliseconds / 1000);
+ const minutes = Math.floor(seconds / 60);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+
+ // Approximate months and years
+ const startYear = startDate.getFullYear();
+ const startMonth = startDate.getMonth();
+ const endYear = endDate.getFullYear();
+ const endMonth = endDate.getMonth();
+
+ const months = (endYear - startYear) * 12 + (endMonth - startMonth);
+ const years = Math.floor(months / 12);
+
+ return {
+ milliseconds,
+ seconds,
+ minutes,
+ hours,
+ days,
+ months,
+ years
+ };
+};
+
+export const formatTimeDifference = (
+ difference: TimeDifference,
+ includeUnits: TimeUnit[] = unitHierarchy.slice(0, -1)
+): string => {
+ const timeUnits: { key: TimeUnit; value: number; divisor?: number }[] = [
+ { key: 'years', value: difference.years },
+ { key: 'months', value: difference.months, divisor: 12 },
+ { key: 'days', value: difference.days, divisor: 30 },
+ { key: 'hours', value: difference.hours, divisor: 24 },
+ { key: 'minutes', value: difference.minutes, divisor: 60 },
+ { key: 'seconds', value: difference.seconds, divisor: 60 }
+ ];
+
+ const parts = timeUnits
+ .filter(({ key }) => includeUnits.includes(key))
+ .map(({ key, value, divisor }) => {
+ const remaining = divisor ? value % divisor : value;
+ return remaining > 0 ? `${remaining} ${key}` : '';
+ })
+ .filter(Boolean);
+
+ if (parts.length === 0) {
+ if (includeUnits.includes('milliseconds')) {
+ return `${difference.milliseconds} millisecond${
+ difference.milliseconds === 1 ? '' : 's'
+ }`;
+ }
+ return '0 seconds';
+ }
+
+ return parts.join(', ');
+};
+
+export const getTimeWithTimezone = (
+ dateString: string,
+ timeString: string,
+ timezone: string
+): Date => {
+ // Combine date and time
+ const dateTimeString = `${dateString}T${timeString}Z`; // Append 'Z' to enforce UTC parsing
+ const utcDate = new Date(dateTimeString);
+
+ if (isNaN(utcDate.getTime())) {
+ throw new Error('Invalid date or time format');
+ }
+
+ // If timezone is "local", return the local date
+ if (timezone === 'local') {
+ return utcDate;
+ }
+
+ // Extract offset from timezone (e.g., "GMT+5:30" or "GMT-4")
+ const match = timezone.match(/^GMT(?:([+-]\d{1,2})(?::(\d{2}))?)?$/);
+ if (!match) {
+ throw new Error('Invalid timezone format');
+ }
+
+ const offsetHours = match[1] ? parseInt(match[1], 10) : 0;
+ const offsetMinutes = match[2] ? parseInt(match[2], 10) : 0;
+
+ const totalOffsetMinutes =
+ offsetHours * 60 + (offsetHours < 0 ? -offsetMinutes : offsetMinutes);
+
+ // Adjust the UTC date by the timezone offset
+ return new Date(utcDate.getTime() - totalOffsetMinutes * 60 * 1000);
+};
+
+// Helper function to format time based on largest unit
+export const formatTimeWithLargestUnit = (
+ difference: TimeDifference,
+ largestUnit: TimeUnit
+): string => {
+ const largestUnitIndex = unitHierarchy.indexOf(largestUnit);
+ const unitsToInclude = unitHierarchy.slice(largestUnitIndex);
+
+ // Preserve only whole values, do not apply fractional conversions
+ const adjustedDifference: TimeDifference = { ...difference };
+
+ return formatTimeDifference(adjustedDifference, unitsToInclude);
+};
diff --git a/src/pages/tools/time/time-between-dates/time-between-dates.service.test.ts b/src/pages/tools/time/time-between-dates/time-between-dates.service.test.ts
new file mode 100644
index 0000000..5a8db7f
--- /dev/null
+++ b/src/pages/tools/time/time-between-dates/time-between-dates.service.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from 'vitest';
+import {
+ calculateTimeBetweenDates,
+ formatTimeDifference,
+ formatTimeWithLargestUnit,
+ getTimeWithTimezone
+} from './service';
+
+// Utility function to create a date
+const createDate = (
+ year: number,
+ month: number,
+ day: number,
+ hours = 0,
+ minutes = 0,
+ seconds = 0
+) => new Date(Date.UTC(year, month - 1, day, hours, minutes, seconds));
+describe('calculateTimeBetweenDates', () => {
+ it('should calculate the correct time difference', () => {
+ const startDate = createDate(2023, 1, 1);
+ const endDate = createDate(2024, 1, 1);
+ const result = calculateTimeBetweenDates(startDate, endDate);
+
+ expect(result.years).toBe(1);
+ expect(result.months).toBe(12);
+ expect(result.days).toBeGreaterThanOrEqual(365);
+ });
+
+ it('should swap dates if startDate is after endDate', () => {
+ const startDate = createDate(2024, 1, 1);
+ const endDate = createDate(2023, 1, 1);
+ const result = calculateTimeBetweenDates(startDate, endDate);
+ expect(result.years).toBe(1);
+ });
+});
+
+describe('formatTimeDifference', () => {
+ it('should format time difference correctly', () => {
+ const difference = {
+ years: 1,
+ months: 2,
+ days: 10,
+ hours: 5,
+ minutes: 30,
+ seconds: 0,
+ milliseconds: 0
+ };
+ expect(formatTimeDifference(difference)).toBe(
+ '1 years, 2 months, 10 days, 5 hours, 30 minutes'
+ );
+ });
+
+ it('should return 0 seconds if all values are zero', () => {
+ expect(
+ formatTimeDifference({
+ years: 0,
+ months: 0,
+ days: 0,
+ hours: 0,
+ minutes: 0,
+ seconds: 0,
+ milliseconds: 0
+ })
+ ).toBe('0 seconds');
+ });
+});
+
+describe('getTimeWithTimezone', () => {
+ it('should convert UTC date to specified timezone', () => {
+ const date = getTimeWithTimezone('2025-03-27', '12:00:00', 'GMT+2');
+ expect(date.getUTCHours()).toBe(10); // 12:00 GMT+2 is 10:00 UTC
+ });
+
+ it('should throw error for invalid timezone', () => {
+ expect(() =>
+ getTimeWithTimezone('2025-03-27', '12:00:00', 'INVALID')
+ ).toThrow('Invalid timezone format');
+ });
+});
+
+describe('formatTimeWithLargestUnit', () => {
+ it('should format time with the largest unit', () => {
+ const difference = {
+ years: 0,
+ months: 1,
+ days: 15,
+ hours: 12,
+ minutes: 0,
+ seconds: 0,
+ milliseconds: 0
+ };
+ expect(formatTimeWithLargestUnit(difference, 'days')).toContain(
+ '15 days, 12 hours'
+ );
+ });
+});
diff --git a/src/tools/defineTool.tsx b/src/tools/defineTool.tsx
index e32b940..5263c6f 100644
--- a/src/tools/defineTool.tsx
+++ b/src/tools/defineTool.tsx
@@ -21,6 +21,7 @@ export type ToolCategory =
| 'video'
| 'list'
| 'json'
+ | 'time'
| 'csv'
| 'time'
| 'pdf';
diff --git a/src/tools/index.ts b/src/tools/index.ts
index 57596f3..401cbb3 100644
--- a/src/tools/index.ts
+++ b/src/tools/index.ts
@@ -16,12 +16,12 @@ export const tools: DefinedTool[] = [
...imageTools,
...stringTools,
...jsonTools,
+ ...pdfTools,
...listTools,
...csvTools,
...videoTools,
...numberTools,
- ...timeTools,
- ...pdfTools
+ ...timeTools
];
const categoriesConfig: {
type: ToolCategory;
@@ -66,6 +66,12 @@ const categoriesConfig: {
value:
'Tools for working with JSON data structures – prettify and minify JSON objects, flatten JSON arrays, stringify JSON values, analyze data, and much more'
},
+ {
+ type: 'time',
+ icon: 'mdi:clock-time-five',
+ value:
+ 'Tools for working with time and date – calculate time differences, convert between time zones, format dates, generate date sequences, and much more.'
+ },
{
type: 'csv',
icon: 'material-symbols-light:csv-outline',
@@ -91,6 +97,11 @@ const categoriesConfig: {
'Tools for working with time and date – draw clocks and calendars, generate time and date sequences, calculate average time, convert between time zones, and much more.'
}
];
+// use for changelogs
+// console.log(
+// 'tools',
+// tools.map(({ name, type }) => ({ type, name }))
+// );
export const filterTools = (
tools: DefinedTool[],
query: string