diff --git a/components/seo/TimezoneConverterSEO.tsx b/components/seo/TimezoneConverterSEO.tsx new file mode 100644 index 0000000..1cdf586 --- /dev/null +++ b/components/seo/TimezoneConverterSEO.tsx @@ -0,0 +1,127 @@ +import CodeExample from "../CodeExample"; + +export default function TimezoneConverterSEO() { + return ( +
+
+

+ Easily convert times between different timezones with this free tool. + Perfect for remote software teams who need to schedule syncs, + meetings, and standups across multiple timezones. No more mental math + or timezone confusion. +

+
+ +
+

Features:

+ +
+ +
+

How to use the Timezone Converter:

+ +
+ +
+

Why Use a Timezone Converter?

+

+ Remote teams often span multiple continents and timezones. Scheduling + meetings that work for everyone can be challenging. A timezone + converter helps you: +

+ +
+ +
+

Convert Timezones in JavaScript:

+

+ If you need to convert timezones programmatically in your own + applications, here is a code snippet using the Intl API: +

+
+ +
+ {jsCodeExample} +
+ +
+

FAQs:

+ +
+
+ ); +} + +const jsCodeExample = `function convertTimezone(date: Date, fromTz: string, toTz: string): string { + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: toTz, + hour: '2-digit', + minute: '2-digit', + hour12: true, + weekday: 'short', + month: 'short', + day: 'numeric', + }); + + return formatter.format(date); +} + +// Example usage: +const nyTime = new Date('2024-01-15T09:00:00'); +console.log(convertTimezone(nyTime, 'America/New_York', 'Europe/London')); +// Output: "Mon, Jan 15, 02:00 PM" +`; diff --git a/components/utils/timezone-converter.utils.ts b/components/utils/timezone-converter.utils.ts new file mode 100644 index 0000000..f5fef77 --- /dev/null +++ b/components/utils/timezone-converter.utils.ts @@ -0,0 +1,158 @@ +export interface TimezoneInfo { + id: string; + label: string; + offset: string; +} + +export const commonTimezones: TimezoneInfo[] = [ + { id: "America/Los_Angeles", label: "Los Angeles (PT)", offset: "" }, + { id: "America/Denver", label: "Denver (MT)", offset: "" }, + { id: "America/Chicago", label: "Chicago (CT)", offset: "" }, + { id: "America/New_York", label: "New York (ET)", offset: "" }, + { id: "America/Sao_Paulo", label: "Sao Paulo (BRT)", offset: "" }, + { id: "Europe/London", label: "London (GMT/BST)", offset: "" }, + { id: "Europe/Paris", label: "Paris (CET)", offset: "" }, + { id: "Europe/Berlin", label: "Berlin (CET)", offset: "" }, + { id: "Asia/Dubai", label: "Dubai (GST)", offset: "" }, + { id: "Asia/Kolkata", label: "India (IST)", offset: "" }, + { id: "Asia/Singapore", label: "Singapore (SGT)", offset: "" }, + { id: "Asia/Tokyo", label: "Tokyo (JST)", offset: "" }, + { id: "Australia/Sydney", label: "Sydney (AEST)", offset: "" }, +]; + +export function getTimezoneOffset(timezone: string, date: Date): string { + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + timeZoneName: "shortOffset", + }); + const parts = formatter.formatToParts(date); + const offsetPart = parts.find((part) => part.type === "timeZoneName"); + return offsetPart?.value || ""; +} + +export function convertTime( + sourceTime: string, + sourceDate: string, + sourceTimezone: string, + targetTimezones: string[] +): { timezone: string; label: string; time: string; date: string }[] { + if (!sourceTime || !sourceDate) { + return []; + } + + const [hours, minutes] = sourceTime.split(":").map(Number); + const [year, month, day] = sourceDate.split("-").map(Number); + + const sourceFormatter = new Intl.DateTimeFormat("en-US", { + timeZone: sourceTimezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + + const tempDate = new Date(year, month - 1, day, hours, minutes); + const sourceParts = sourceFormatter.formatToParts(tempDate); + + const getPartValue = ( + parts: Intl.DateTimeFormatPart[], + type: Intl.DateTimeFormatPartTypes + ) => parts.find((p) => p.type === type)?.value || ""; + + const sourceYear = parseInt(getPartValue(sourceParts, "year")); + const sourceMonth = parseInt(getPartValue(sourceParts, "month")); + const sourceDay = parseInt(getPartValue(sourceParts, "day")); + const sourceHour = parseInt(getPartValue(sourceParts, "hour")); + const sourceMinute = parseInt(getPartValue(sourceParts, "minute")); + + const utcDate = new Date( + Date.UTC(sourceYear, sourceMonth - 1, sourceDay, sourceHour, sourceMinute) + ); + + const sourceOffset = getTimezoneOffsetMinutes(sourceTimezone, utcDate); + utcDate.setMinutes(utcDate.getMinutes() - sourceOffset); + + return targetTimezones.map((tz) => { + const tzInfo = commonTimezones.find((t) => t.id === tz); + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: tz, + hour: "2-digit", + minute: "2-digit", + hour12: true, + weekday: "short", + month: "short", + day: "numeric", + }); + + const formatted = formatter.format(utcDate); + const parts = formatted.split(", "); + const weekday = parts[0]; + const datePart = parts[1]; + const timePart = parts[2]; + + return { + timezone: tz, + label: tzInfo?.label || tz, + time: timePart, + date: `${weekday}, ${datePart}`, + }; + }); +} + +function getTimezoneOffsetMinutes(timezone: string, date: Date): number { + const utcDate = new Date(date.toLocaleString("en-US", { timeZone: "UTC" })); + const tzDate = new Date(date.toLocaleString("en-US", { timeZone: timezone })); + return (tzDate.getTime() - utcDate.getTime()) / 60000; +} + +export function getCurrentTimeInTimezone(timezone: string): { + time: string; + date: string; +} { + const now = new Date(); + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + hour: "2-digit", + minute: "2-digit", + hour12: true, + weekday: "short", + month: "short", + day: "numeric", + }); + + const formatted = formatter.format(now); + const parts = formatted.split(", "); + const weekday = parts[0]; + const datePart = parts[1]; + const timePart = parts[2]; + + return { + time: timePart, + date: `${weekday}, ${datePart}`, + }; +} + +export function formatTimeForInput(date: Date, timezone: string): string { + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: timezone, + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + const parts = formatter.formatToParts(date); + const hour = parts.find((p) => p.type === "hour")?.value || "00"; + const minute = parts.find((p) => p.type === "minute")?.value || "00"; + return `${hour}:${minute}`; +} + +export function formatDateForInput(date: Date, timezone: string): string { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + return formatter.format(date); +} diff --git a/components/utils/tools-list.ts b/components/utils/tools-list.ts index e4c3ada..1ad74d0 100644 --- a/components/utils/tools-list.ts +++ b/components/utils/tools-list.ts @@ -161,4 +161,10 @@ export const tools = [ "Generate cryptographically secure random strings with configurable character sets. Perfect for API keys, tokens, passwords, and secure identifiers.", link: "/utilities/random-string-generator", }, + { + title: "Timezone Converter", + description: + "Convert times between timezones for remote team scheduling. Perfect for distributed software teams coordinating meetings across multiple timezones.", + link: "/utilities/timezone-converter", + }, ]; diff --git a/pages/utilities/timezone-converter.tsx b/pages/utilities/timezone-converter.tsx new file mode 100644 index 0000000..c670c16 --- /dev/null +++ b/pages/utilities/timezone-converter.tsx @@ -0,0 +1,250 @@ +import { useCallback, useState, useEffect } from "react"; +import { useRouter } from "next/router"; +import PageHeader from "@/components/PageHeader"; +import { Card } from "@/components/ds/CardComponent"; +import { Button } from "@/components/ds/ButtonComponent"; +import { Label } from "@/components/ds/LabelComponent"; +import { Input } from "@/components/ds/InputComponent"; +import { Checkbox } from "@/components/ds/CheckboxComponent"; +import Header from "@/components/Header"; +import { CMDK } from "@/components/CMDK"; +import { useCopyToClipboard } from "@/components/hooks/useCopyToClipboard"; +import TimezoneConverterSEO from "@/components/seo/TimezoneConverterSEO"; +import CallToActionGrid from "@/components/CallToActionGrid"; +import Meta from "@/components/Meta"; +import { + commonTimezones, + convertTime, + formatTimeForInput, + formatDateForInput, +} from "@/components/utils/timezone-converter.utils"; + +function getQueryParam( + value: string | string[] | undefined +): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + +export default function TimezoneConverter() { + const router = useRouter(); + const [sourceTimezone, setSourceTimezone] = useState("America/New_York"); + const [sourceTime, setSourceTime] = useState(""); + const [sourceDate, setSourceDate] = useState(""); + const [selectedTimezones, setSelectedTimezones] = useState( + commonTimezones.map((tz) => tz.id) + ); + const [convertedTimes, setConvertedTimes] = useState< + { timezone: string; label: string; time: string; date: string }[] + >([]); + const [isInitialized, setIsInitialized] = useState(false); + const { buttonText, handleCopy } = useCopyToClipboard(); + const [linkButtonText, setLinkButtonText] = useState("Copy Link to Share"); + + useEffect(() => { + if (!router.isReady) return; + + const timeParam = getQueryParam(router.query.time); + const dateParam = getQueryParam(router.query.date); + const tzParam = getQueryParam(router.query.tz); + + const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const now = new Date(); + + const tzFromQuery = + tzParam && commonTimezones.some((t) => t.id === tzParam) + ? tzParam + : commonTimezones.some((t) => t.id === userTimezone) + ? userTimezone + : "America/New_York"; + + const timeFromQuery = + timeParam && /^\d{2}:\d{2}$/.test(timeParam) + ? timeParam + : formatTimeForInput(now, tzFromQuery); + + const dateFromQuery = + dateParam && /^\d{4}-\d{2}-\d{2}$/.test(dateParam) + ? dateParam + : formatDateForInput(now, tzFromQuery); + + setSourceTimezone(tzFromQuery); + setSourceTime(timeFromQuery); + setSourceDate(dateFromQuery); + setIsInitialized(true); + }, [router.isReady, router.query]); + + useEffect(() => { + if (!router.isReady || !isInitialized) return; + if (!sourceTime || !sourceDate || !sourceTimezone) return; + + router.replace( + { + pathname: router.pathname, + query: { + time: sourceTime, + date: sourceDate, + tz: sourceTimezone, + }, + }, + undefined, + { shallow: true } + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sourceTime, sourceDate, sourceTimezone, router.isReady, isInitialized]); + + useEffect(() => { + if (sourceTime && sourceDate) { + const results = convertTime( + sourceTime, + sourceDate, + sourceTimezone, + selectedTimezones + ); + setConvertedTimes(results); + } + }, [sourceTime, sourceDate, sourceTimezone, selectedTimezones]); + + const handleTimezoneToggle = useCallback((timezoneId: string) => { + setSelectedTimezones((prev) => + prev.includes(timezoneId) + ? prev.filter((tz) => tz !== timezoneId) + : [...prev, timezoneId] + ); + }, []); + + const handleCopyAll = useCallback(() => { + const text = convertedTimes + .map((ct) => `${ct.label}: ${ct.time} (${ct.date})`) + .join("\n"); + handleCopy(text); + }, [convertedTimes, handleCopy]); + + const handleCopyLink = useCallback(() => { + if (typeof window === "undefined") return; + navigator.clipboard.writeText(window.location.href).then(() => { + setLinkButtonText("Copied!"); + setTimeout(() => setLinkButtonText("Copy Link to Share"), 2000); + }); + }, []); + + const getSourceTimezoneLabel = () => { + const tz = commonTimezones.find((t) => t.id === sourceTimezone); + return tz?.label || sourceTimezone; + }; + + return ( +
+ +
+ + +
+ +
+ +
+ +
+ + +
+ +
+
+ + setSourceTime(e.target.value)} + /> +
+
+ + setSourceDate(e.target.value)} + /> +
+
+ +
+ +
+ {commonTimezones.map((tz) => ( +
+ handleTimezoneToggle(tz.id)} + /> + +
+ ))} +
+
+ + {convertedTimes.length > 0 && ( +
+ +
+ {convertedTimes.map((ct) => ( +
+ {ct.label} +
+ {ct.time} + + {ct.date} + +
+
+ ))} +
+
+ )} + +
+ + +
+
+
+ + + +
+ +
+
+ ); +}